OBS 유튜브 라이브 채팅 오버레이 프로그램 (OBSLiveChat, AutoHotkey v2)
목차
더 편하게 읽는 방법 (목차로 바로 이동)
아래 코드는 2만 줄이 넘고 파일이 65개라 이 페이지에서 쭉 훑어보기엔 불편할 수 있습니다. 파일마다 색깔로 구분되고, 우측 목차에서 원하는 파일을 클릭하면 바로 그 자리로 이동하는 정리된 버전을 따로 만들어뒀습니다. 코드를 직접 뜯어보고 싶으시다면 이쪽을 먼저 열어보시길 추천합니다.
이 글은 제가 방송용으로 만들어 쓰고 있는 OBSLiveChat의 최종본 전체를 공개하는 글입니다.
AutoHotkey v2로 만든 작은 프로그램이고, 유튜브 라이브 채팅을 OBS 화면 위에 투명 배경으로 띄워 줍니다. 방송 중에는 관리자 창에서 직접 글을 써서 보내거나, 미리 등록해 둔 매크로를 한 번 클릭으로 송출할 수 있고, 관리자 계정은 일반 시청자와 다른 색으로 구분해서 보여줍니다. AI와 함께 작업하며 조금씩 다듬어 온 프로그램입니다.
이 글의 구성 방침: 실행에 필요한 폴더 구조와 소스 파일은 빠짐없이 올렸습니다. 각 파일은 아래 목차에서 클릭해서 바로 이동할 수 있습니다. 다만 백업본(
Bak,OLDBAK등), 폰트 파일, 실행 중 쌓이는 기록 파일은 본문에서 뺐고, YouTube API 키는 가렸으며 관리자 ID·매크로 파일은 형식만 보이는 예시로 바꿨습니다. 그대로 복붙하기보다는 참고용으로 봐주세요.
🟡 프로젝트 소개 #
🟡 어떤 프로그램인가 #
- 송출 오버레이: OBS의 “윈도우 캡처"로 불러오는 투명 창입니다. 메시지 종류(일반·관리자·멤버·후원·공지·시스템·VIP)마다 글꼴, 색, 외곽선, 그림자, 글로우를 따로 지정할 수 있습니다.
- 관리자 창: 작성자·종류·메시지를 입력해 바로 송출하고, 자주 쓰는 문장은 카테고리별 매크로로 등록해 두었다가 원클릭으로 보냅니다. 작성자는 “관리자 ID"에 등록한 이름을 드롭다운에서 고를 수 있습니다.
- 입력 검사: Enter로 바로 송출하거나, Enter를 끄고 “검사” 버튼을 통과해야만 “송출"이 활성화되게 할 수 있습니다. 빈 입력, 괄호·따옴표 짝 오류 같은 실수를 걸러줍니다.
- 유튜브 연동: 방송 시작할 때 라이브 URL을 붙여넣고 “연결"을 누르면, YouTube Data API로 채팅을 받아 같은 오버레이에 표시합니다.
- 그 밖의 기능: 최근 보낸 메시지 목록, 표시 개수 조절, 작업 기록 창, 시스템 트레이 상주, 5분 동안 채팅이 없으면 오버레이가 서서히 사라지고 새 채팅이 오면 다시 나타나는 페이드.
🟡 실행 방법 #
- AutoHotkey v2를 설치합니다. (
#Requires AutoHotkey v2.0+) - 아래 폴더 구조대로 파일을 배치하고
OBSLive.ahk를 실행합니다. 관리자 창과 투명 송출 창이 함께 뜹니다. - OBS에서 소스 → 윈도우 캡처를 추가하고
OBSLiveChat 송출 오버레이창을 선택합니다. - 유튜브 채팅을 받으려면
Data/Settings/YoutubeApiKey.ini에 본인의 YouTube Data API 키를 넣고, 관리자 창의 “라이브URL"에 방송 주소를 붙여넣은 뒤 “연결"을 누릅니다. - 사용자 폰트를 쓰려면
.ttf/.otf파일을Assets/Fonts폴더에 넣고,Data/Settings/Styles.ini의FontName에 글꼴 이름을 적습니다.
🟡 폴더 구조 #
OBSLiveChat/
├─ App/
│ ├─ Core/
│ │ ├─ Application.ahk # 설정과 서비스 생성, 창 연결, 시작·종료 관리
│ │ ├─ Common.ahk # (빈 파일)
│ │ ├─ EventManager.ah.ahk # (빈 파일)
│ │ ├─ Logger.ahk # (빈 파일)
│ │ ├─ Settings.ahk # (빈 파일)
│ │ └─ WindowManager.ahk # 창 객체 생성·표시 관리
│ └─ Windows/
│ ├─ AdminIdentityWindow.ahk # 관리자 ID 등록·삭제 창
│ ├─ AdminWindow.ahk # 관리자 창 (작성자·종류·메시지 입력, 매크로 목록, 유튜브 연결)
│ ├─ AdminWindowBak2.ahk # [본문 제외] 백업본
│ ├─ AdminWindowmain.ahk # [본문 제외] 백업본
│ ├─ AdminWindowOLDBAK.ahk # [본문 제외] 백업본
│ ├─ LogWindow.ahk # 작업·송출 기록 창
│ └─ OverlayWindow.ahk # OBS로 캡처하는 투명 송출 창
├─ Assets/
│ └─ Fonts/
│ ├─ SEBANG Gothic Bold.ttf # [본문 제외] 폰트 파일
│ ├─ SEBANG Gothic.ttf # [본문 제외] 폰트 파일
│ └─ 이사만루.ttf # [본문 제외] 폰트 파일
├─ Data/
│ ├─ Logs/
│ │ └─ ChatEvents.tsv # [본문 제외] 실행 중 쌓이는 기록
│ ├─ Macros/
│ │ └─ ChatMacros.tsv # 채팅 매크로 목록 (본문에서는 예시로 대체)
│ └─ Settings/
│ ├─ AdminIdentities.tsv # 관리자 ID 목록 (본문에서는 예시로 대체)
│ ├─ Fonts.ini # (빈 파일)
│ ├─ MessageHistory.txt # [본문 제외] 실행 중 쌓이는 기록
│ ├─ Overlay.ini # 송출 창 크기·위치·여백·표시 개수 설정
│ ├─ Program.ini # (빈 파일)
│ ├─ Styles - 복사본.ini # [본문 제외] 백업본
│ ├─ Styles.ini # 메시지 종류별 글꼴·색·효과 설정
│ ├─ Styles_OBS20.ini # [본문 제외] 미사용 옛 스타일
│ ├─ Theme.ini # (빈 파일)
│ └─ YoutubeApiKey.ini # 유튜브 API 키 (본문에서는 가림)
├─ Docs/
│ ├─ ARCHITECTURE.md # 구조 설계 문서
│ ├─ CHANGELOG.md # (빈 파일)
│ ├─ CODING_STANDARD.md # 코딩 규칙 문서
│ ├─ PROJECT_PHILOSOPHY.md # 프로젝트 철학 문서
│ └─ ROADMAP.md # (빈 파일)
├─ Engine/
│ ├─ Configuration/
│ │ ├─ FontLoader.ahk # (빈 파일)
│ │ ├─ MacroLoader.ahk # (빈 파일)
│ │ ├─ OverlaySettingsLoader.ahk # Overlay.ini 읽기 (크기·위치·여백·표시 개수)
│ │ ├─ SettingsManager.ahk # (빈 파일)
│ │ ├─ StyleLoader.ahk # Styles.ini를 읽어 종류별 TextStyle 표 만들기
│ │ ├─ ThemeLoader.ahk # (빈 파일)
│ │ └─ 새 AutoHotkey Script.ahk # (빈 파일)
│ ├─ Model/
│ │ ├─ Channel.ahk # (빈 파일)
│ │ ├─ ChatMacro.ahk # 채팅 매크로 1개
│ │ ├─ ChatMessage.ahk # 채팅 메시지 1건
│ │ ├─ MessageBuffer.ahk # 최근 메시지 N개만 유지하는 버퍼
│ │ └─ User.ahk # (빈 파일)
│ ├─ Pipeline/
│ │ ├─ StyleResolver.ahk # 메시지 종류에 맞는 스타일 찾기
│ │ ├─ TextLayout.ahk # 실제 글자 폭을 재서 자동 줄바꿈·배치
│ │ ├─ ViewportLayout.ahk # 화면 높이에 맞춰 보이는 메시지 묶음 결정
│ │ └─ VisibleMessageWindowSizer.ahk # 표시 개수에 맞춰 송출 창 높이 계산
│ ├─ Providers/
│ │ ├─ ChzzkProvider.ahk # (빈 파일)
│ │ ├─ KickProvider.ahk # (빈 파일)
│ │ ├─ TwitchProvider.ahk # (빈 파일)
│ │ └─ YoutubeProvider.ahk # 유튜브 라이브 채팅 가져오기 (API 폴링)
│ ├─ Rendering/
│ │ ├─ AnimationManager.ahk # (빈 파일)
│ │ ├─ FontManager.ahk # (빈 파일)
│ │ ├─ GdiTextMeasurer.ahk # GDI로 글자 폭·높이 측정
│ │ ├─ LayeredOverlayRenderer.ahk # 투명 오버레이 렌더러 (픽셀별 알파 · 글로우 · 그림자 · 외곽선)
│ │ ├─ OverlayRenderer.ahk # GDI 직접 그리기 렌더러 (Build 1.7 방식, 테스트에서 사용)
│ │ ├─ TextDrawCommand.ahk # 렌더러에 넘기는 그리기 명령 데이터
│ │ └─ TextStyle.ahk # 글꼴·색·효과를 담는 스타일 데이터
│ ├─ Services/
│ │ ├─ AdminIdentityRepository.ahk # 관리자 ID 저장소 (TSV, 캐시)
│ │ ├─ ChatMacroRepository.ahk # 채팅 매크로 저장소 (TSV)
│ │ ├─ EventDispatcher.ahk (나중 EventBus).ahk # (빈 파일)
│ │ ├─ EventLogService.ahk # 작업·송출 기록 저장 (TSV)
│ │ ├─ LiveOverlayController.ahk # 메시지 추가 → 배치 → 렌더 갱신 총괄
│ │ ├─ MessagePipeline.ahk # 버퍼 → 스타일 → 측정 → 그리기 명령 변환
│ │ ├─ RenderingPipeline.ahk # (빈 파일)
│ │ └─ ServiceLocator.ahk (나중 DI).ahk # (빈 파일)
│ ├─ Utilities/
│ │ ├─ Json.ahk # 최소 JSON 파서
│ │ └─ ValueParser.ahk # 설정 문자열을 Boolean·정수·색상 등으로 변환
│ └─ Validation/
│ └─ MessageTextValidator.ahk # 메시지 입력 검사 (빈 값·길이·괄호·따옴표)
├─ Tests/
│ ├─ AdaptiveTextLayoutTest.ahk # 실제 글자 폭·높이로 메시지가 겹치지 않는지 검사
│ ├─ AutoWrapOverlayTest.ahk # 자동 줄바꿈 화면 테스트 (긴 URL 분할 포함)
│ ├─ EnterModeValidationTest.ahk # Enter 전송 ON/OFF + 입력 검사 테스트
│ ├─ ExtendedStyleLoaderTest.ahk # 스타일 상속·덮어쓰기·확장 속성 로딩 검사
│ ├─ ExtendedTextStyleTest.ahk # 확장 TextStyle 생성·속성 검증
│ ├─ LayeredGlowOverlayTest.ahk # 픽셀별 알파 글로우 화면 테스트
│ ├─ LayeredOverlayTest.ahk # 레이어드 알파 기반 화면 테스트
│ ├─ LiveChatFetchTest.ahk # YoutubeProvider 단독 동작 확인
│ ├─ MacroAdminOverlayTest.ahk # 관리자 창 통합 테스트 (매크로·카테고리·표시 개수)
│ ├─ ManualInputOverlayTest.ahk # 관리자 수동 입력 + 실시간 오버레이 테스트
│ ├─ MessageBufferTest.ahk # 메시지 버퍼 개수 제한 검사
│ ├─ MessagePipelineTest.ahk # 버퍼 → 스타일 → 배치 → 그리기 명령 전체 연결 검사
│ ├─ OverlayRendererTest.ahk # Styles.ini부터 렌더러까지 실제 창에 그려보기
│ ├─ StyleEncodingTest.ahk # Styles.ini를 UTF-8로 직접 읽어 확인
│ ├─ StyleLoaderBooleanTest.ahk # 스타일 로더의 Boolean 값 처리 검사
│ ├─ StyleLoaderTest.ahk # Styles.ini → 스타일 표 변환 검사
│ ├─ StyleResolverTest.ahk # 스타일 조회·기본 처리·잘못된 객체 거부 검사
│ ├─ TextDrawCommandTest.ahk # 그리기 명령 생성·잘못된 스타일 거부 검사
│ ├─ TextLayoutTest.ahk # 메시지 배열 → 좌표가 있는 그리기 명령 검사
│ ├─ TextStyleTest.ahk # TextStyle 생성·잘못된 속성 거부 검사
│ ├─ TopFixedVisibleCountTest.ahk # 표시 개수 입력 + 위쪽 고정 테스트
│ ├─ TransparentOverlayTest.ahk # 투명 오버레이 기초 테스트
│ ├─ ValueParserTest.ahk # 설정값 변환 검사
│ └─ ViewportOverlayTest.ahk # 뷰포트(위로 밀려 올라가기) 테스트
├─ config.ini # (빈 파일)
├─ OBSLive.ahk # 실행 진입점 (Include 목록 · 트레이 메뉴 · Enter 단축키)
├─ OBSLiveBAK.ahk # [본문 제외] 백업본
├─ OBSLiveBak1.ahk # [본문 제외] 백업본
├─ OBSLiveBak2.ahk # [본문 제외] 백업본
├─ OBSLiveESC.ahk # [본문 제외] 백업본
├─ README_Build_1.9.1.txt # Build 1.9.1 노트
├─ README_Build_1.9.2.txt # Build 1.9.2 노트
├─ README_Build_1.9.txt # Build 1.9 노트
├─ README_Build_2.0.1.txt # Build 2.0.1 노트 (종료 오류 수정)
├─ README_Build_2.0.txt # Build 2.0 노트 (자동 줄바꿈)
└─ README_Recovery.txt # 복구 패치 노트
🟡 구조와 메시지 흐름 #
App(창과 사용자 입력)은 Engine(메시지 처리·배치·렌더링·외부 서비스)을 사용하지만,
Engine은 App을 전혀 모릅니다. 메시지는 아래 순서로 흘러갑니다.
유튜브 채팅(Provider) 또는 관리자 창 입력
↓
ChatMessage
↓
MessageBuffer (최근 N개만 유지)
↓
MessagePipeline (스타일 결정 → TextLayout으로 측정·배치)
↓
TextDrawCommand
↓
LayeredOverlayRenderer (투명 캔버스에 그리기)
↓
OverlayWindow (OBS가 캡처)
🟣 진입점 — 시작 파일 #
🟣 OBSLive.ahk — 실행 진입점 (Include 목록 · 트레이 메뉴 · Enter 단축키) #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include Engine\Configuration\StyleLoader.ahk
#Include Engine\Configuration\OverlaySettingsLoader.ahk
#Include Engine\Services\LiveOverlayController.ahk
#Include Engine\Validation\MessageTextValidator.ahk
#Include Engine\Services\ChatMacroRepository.ahk
#Include Engine\Services\EventLogService.ahk
#Include Engine\Services\AdminIdentityRepository.ahk
#Include Engine\Pipeline\VisibleMessageWindowSizer.ahk
#Include Engine\Rendering\LayeredOverlayRenderer.ahk
#Include App\Windows\OverlayWindow.ahk
#Include App\Windows\LogWindow.ahk
#Include App\Windows\AdminIdentityWindow.ahk
#Include App\Windows\AdminWindow.ahk
#Include App\Core\Application.ahk
;============================================================
; Recovery: Build 2.0 style + Enter fix
; Main은 시작과 단축키 연결만 담당한다.
;============================================================
applicationInstance :=
Application(
A_ScriptDir
)
applicationInstance.Run()
; 2026-07 추가:
; 관리자 창을 작업표시줄에서 숨기는 대신(+ToolWindow),
; 트레이 아이콘을 더블클릭하면 관리자 창이 다시 앞으로 나오도록
; 트레이 메뉴를 새로 구성한다.
A_TrayMenu.Delete()
A_TrayMenu.Add(
"관리자 창 열기",
ShowAdminWindow
)
A_TrayMenu.Add()
A_TrayMenu.Add(
"종료",
ExitFromTray
)
A_TrayMenu.Default := "관리자 창 열기"
A_IconTip := "OBSLiveChat"
ShowAdminWindow(*)
{
global applicationInstance
; 숨겨진(Hide()된) 창은 기본 설정으로는 WinExist 등이
; 못 찾는다. 이걸 켜야 "숨기기" 버튼으로 숨긴 창을
; 트레이에서 다시 찾아 보여줄 수 있다.
DetectHiddenWindows(true)
if (
IsObject(applicationInstance.AdminView)
&& WinExist(
"ahk_id "
. applicationInstance.AdminView.Window.Hwnd
)
)
{
WinShow(
"ahk_id "
. applicationInstance.AdminView.Window.Hwnd
)
WinRestore(
"ahk_id "
. applicationInstance.AdminView.Window.Hwnd
)
WinActivate(
"ahk_id "
. applicationInstance.AdminView.Window.Hwnd
)
}
}
ExitFromTray(*)
{
global applicationInstance
applicationInstance.Shutdown()
}
#HotIf WinActive("OBSLiveChat 관리자")
; Enter KeyDown을 가로채지 않는다.
; 한글 IME와 Edit 컨트롤이 마지막 글자를 먼저 확정한 뒤,
; KeyUp에서 한 번만 송출 처리를 요청한다.
~Enter Up::
{
global applicationInstance
applicationInstance.HandleEnterKey()
}
~NumpadEnter Up::
{
global applicationInstance
applicationInstance.HandleEnterKey()
}
#HotIf
; 2026-07 삭제: Esc 키 전체 종료 단축키를 없앴다.
; 이제 관리자 창의 X 버튼(마우스)으로만 종료된다.
🔴 App · Core — 프로그램 조립 #
🔴 App/Core/Application.ahk — 설정과 서비스 생성, 창 연결, 시작·종료 관리 #
;============================================================
; Application
;
; 역할:
; - 설정과 서비스 생성
; - 관리자 창과 송출창 연결
; - 시작과 종료 관리
;============================================================
class Application
{
__New(projectRootPath)
{
this.ProjectRoot :=
RTrim(
projectRootPath,
"\/"
)
this.IsRunning := false
this.AdminView := 0
this.OverlayView := 0
this.LogView := 0
this.AdminIdentityView := 0
this.OverlayFadeTimerBound := 0
}
Run()
{
if (this.IsRunning)
{
return
}
stylePath :=
this.ProjectRoot
. "\Data\Settings\Styles.ini"
overlaySettingsPath :=
this.ProjectRoot
. "\Data\Settings\Overlay.ini"
macroStoragePath :=
this.ProjectRoot
. "\Data\Macros\ChatMacros.tsv"
eventLogPath :=
this.ProjectRoot
. "\Data\Logs\ChatEvents.tsv"
adminIdentityPath :=
this.ProjectRoot
. "\Data\Settings\AdminIdentities.tsv"
youtubeApiKeyPath :=
this.ProjectRoot
. "\Data\Settings\YoutubeApiKey.ini"
youtubeApiKeyValue :=
IniRead(
youtubeApiKeyPath,
"Youtube",
"ApiKey",
""
)
fontsFolderPath :=
this.ProjectRoot
. "\Assets\Fonts"
messageHistoryPath :=
this.ProjectRoot
. "\Data\Settings\MessageHistory.txt"
styleConfiguration :=
StyleLoader().Load(
stylePath
)
styleResolverObject :=
StyleResolver(
styleConfiguration
)
overlaySettings :=
OverlaySettingsLoader().Load(
overlaySettingsPath
)
textMeasurerObject :=
GdiTextMeasurer()
textLayoutObject :=
TextLayout(
overlaySettings[
"LeftMargin"
],
overlaySettings[
"TopMargin"
],
overlaySettings[
"MinimumMessageHeight"
],
overlaySettings[
"MessageGap"
],
textMeasurerObject,
overlaySettings[
"MaximumTextWidth"
],
overlaySettings[
"TextLineGap"
]
)
visibleWindowSizerObject :=
VisibleMessageWindowSizer(
textLayoutObject,
overlaySettings[
"TopMargin"
],
overlaySettings[
"BottomMargin"
]
)
this.OverlayView :=
OverlayWindow(
styleConfiguration,
styleResolverObject,
textLayoutObject,
visibleWindowSizerObject,
overlaySettings,
fontsFolderPath
)
validatorObject :=
MessageTextValidator(
300
)
repositoryObject :=
ChatMacroRepository(
macroStoragePath
)
eventLogServiceObject :=
EventLogService(
eventLogPath
)
this.LogView :=
LogWindow(
eventLogServiceObject
)
adminIdentityRepositoryObject :=
AdminIdentityRepository(
adminIdentityPath
)
this.AdminIdentityView :=
AdminIdentityWindow(
adminIdentityRepositoryObject,
this.LogView
)
this.AdminView :=
AdminWindow(
repositoryObject,
validatorObject,
this.OverlayView,
this.LogView,
adminIdentityRepositoryObject,
this.AdminIdentityView,
overlaySettingsPath,
ObjBindMethod(
this,
"Shutdown"
),
youtubeApiKeyValue,
messageHistoryPath
)
this.AdminIdentityView.SetChangeCallback(
ObjBindMethod(
this.AdminView,
"UpdateAdminIdentityStatus"
)
)
this.IsRunning := true
this.AdminView.Show()
this.OverlayView.AddMessage(
"시스템",
"OBSLiveChat 관리자와 송출 오버레이가 시작되었습니다.",
"System"
)
this.LogView.AddEvent(
"시스템",
"OBSLiveChat 시작"
)
; 2026-07 추가: 채팅 없이 5분 지나면 서서히 사라지는 기능.
; 1초마다 OverlayView.Tick()을 호출해 유휴 시간을 확인한다.
this.OverlayFadeTimerBound :=
ObjBindMethod(
this,
"SafeOverlayTick"
)
SetTimer(
this.OverlayFadeTimerBound,
1000
)
}
HandleEnterKey()
{
if (
this.IsRunning
&& IsObject(this.AdminView)
)
{
this.AdminView.HandleEnterKey()
}
}
; 2026-07 추가:
; 유휴 페이드 타이머는 1초마다 계속 반복 실행되므로,
; 여기서 예외가 나면 확인 안 된 에러로 프로그램 전체가
; 종료돼버린다. 그래서 반드시 try/catch로 감싸서
; 이 타이머 하나가 실패해도 프로그램은 계속 돌아가게 한다.
SafeOverlayTick(*)
{
try
{
if (IsObject(this.OverlayView))
{
this.OverlayView.Tick()
}
}
catch as errorObject
{
if (IsObject(this.LogView))
{
this.LogView.AddEvent(
"페이드 타이머 오류",
errorObject.Message
)
}
}
}
Shutdown(*)
{
if (!this.IsRunning)
{
ExitApp()
}
this.IsRunning := false
if (IsObject(this.OverlayFadeTimerBound))
{
SetTimer(
this.OverlayFadeTimerBound,
0
)
}
if (IsObject(this.LogView))
{
this.LogView.AddEvent(
"시스템",
"OBSLiveChat 종료"
)
}
if (IsObject(this.AdminView))
{
this.AdminView.Destroy()
}
if (IsObject(this.OverlayView))
{
this.OverlayView.Shutdown()
}
if (IsObject(this.AdminIdentityView))
{
this.AdminIdentityView.Destroy()
}
if (IsObject(this.LogView))
{
this.LogView.Destroy()
}
ExitApp()
}
}
🔴 App/Core/WindowManager.ahk — 창 객체 생성·표시 관리 #
;============================================================
; WindowManager
;
; 프로그램에서 사용하는 창 객체의 생성과 표시를 관리한다.
;
; 창 내부의 컨트롤이나 렌더링 방식은 관리하지 않는다.
;============================================================
#Include ..\Windows\AdminWindow.ahk
#Include ..\..\Engine\Rendering\OverlayRenderer.ahk
#Include ..\Windows\OverlayWindow.ahk
class WindowManager
{
;--------------------------------------------------------
; __New()
; 프로그램에서 사용할 창 객체 생성
;--------------------------------------------------------
__New()
{
this.AdminWindowInstance := AdminWindow()
this.OverlayWindowInstance := OverlayWindow()
}
;--------------------------------------------------------
; ShowMainWindows()
; 프로그램 시작 시 필요한 기본 창 표시
;--------------------------------------------------------
ShowMainWindows()
{
this.AdminWindowInstance.Show()
this.OverlayWindowInstance.Show()
}
}
🔵 App · Windows — 화면(UI) #
🔵 App/Windows/AdminWindow.ahk — 관리자 창 (작성자·종류·메시지 입력, 매크로 목록, 유튜브 연결) #
#Include ..\..\Engine\Providers\YoutubeProvider.ahk
;============================================================
; AdminWindow
;
; 역할:
; - 관리자 UI 생성
; - 수동 메시지 송출
; - 입력 검사와 Enter 전송 ON/OFF
; - 매크로 등록, 삭제, 송출
; - 카테고리 삭제 확인창
; - 표시 개수 적용과 저장
;============================================================
class AdminWindow
{
__New(
repositoryObject,
validatorObject,
overlayWindowObject,
logWindowObject,
adminIdentityRepositoryObject,
adminIdentityWindowObject,
overlaySettingsPath,
exitCallback,
youtubeApiKey := "",
messageHistoryPath := ""
)
{
if (!IsObject(repositoryObject))
{
throw TypeError(
"AdminWindow에는 ChatMacroRepository 객체가 필요합니다."
)
}
if (!IsObject(validatorObject))
{
throw TypeError(
"AdminWindow에는 MessageTextValidator 객체가 필요합니다."
)
}
if (!IsObject(overlayWindowObject))
{
throw TypeError(
"AdminWindow에는 OverlayWindow 객체가 필요합니다."
)
}
if (!IsObject(logWindowObject))
{
throw TypeError(
"AdminWindow에는 LogWindow 객체가 필요합니다."
)
}
if (!IsObject(adminIdentityRepositoryObject))
{
throw TypeError(
"AdminWindow에는 AdminIdentityRepository 객체가 필요합니다."
)
}
if (!IsObject(adminIdentityWindowObject))
{
throw TypeError(
"AdminWindow에는 AdminIdentityWindow 객체가 필요합니다."
)
}
if (!IsObject(exitCallback))
{
throw TypeError(
"AdminWindow에는 종료 Callback이 필요합니다."
)
}
this.Repository :=
repositoryObject
this.Validator :=
validatorObject
this.Overlay :=
overlayWindowObject
this.LogWindow :=
logWindowObject
this.AdminIdentityRepository :=
adminIdentityRepositoryObject
this.AdminIdentityWindow :=
adminIdentityWindowObject
this.OverlaySettingsPath :=
overlaySettingsPath
this.ExitCallback :=
exitCallback
this.ValidationPassed := false
this.EnterPending := false
this.DeleteDialog := 0
this.PendingDeleteCategory := ""
this.PendingDeleteMacroCount := 0
this.YoutubeApiKey := youtubeApiKey
this.YoutubeProviderInstance := 0
this.LivePollBound := 0
this.LivePollFailureCount := 0
; 2026-07 추가: 메시지 입력창이 비어있을 때 클릭하면
; 보여줄 "최근 보낸 메시지" 기록 (최대 10개, 세션 동안만).
; 2026-07 추가: 메시지 입력창이 비어있을 때 클릭하면
; 보여줄 "최근 보낸 메시지" 기록 (최대 10개).
; 파일에 저장해서 프로그램을 껐다 켜도 유지된다.
this.MessageHistoryPath :=
messageHistoryPath
this.MessageHistory :=
this.LoadMessageHistory()
this.HistoryDialog := 0
this.TypeMap :=
Map(
"일반", "Normal",
"관리자", "Admin",
"멤버", "Member",
"후원", "Donation",
"공지", "Notice",
"시스템", "System",
"VIP", "VIP"
)
this.TypeLabelMap :=
Map(
"Normal", "일반",
"Admin", "관리자",
"Member", "멤버",
"Donation", "후원",
"Notice", "공지",
"System", "시스템",
"VIP", "VIP"
)
this.AuthorChoicesSignature := ""
this.AuthorStatusTimerBound := 0
this.CreateWindow()
this.CreateControls()
this.ConnectEvents()
}
CreateWindow()
{
this.Window :=
Gui(
"+AlwaysOnTop +ToolWindow +MinimizeBox",
"OBSLiveChat 관리자"
)
this.Window.SetFont(
"s10",
"맑은 고딕"
)
}
CreateControls()
{
; --------------------------------------------------
; 2026-07 재배치 (7차):
; 라벨 폭을 더 이상 추측하지 않는다. AddText를 너비
; 지정 없이 만들면 AHK가 실제 글자 크기에 딱 맞게
; 자동으로 크기를 잡아준다. 그 실제 크기를 GetPos()로
; 읽어서, 그 바로 뒤(8px)에 입력창을 정확히 배치한다.
; 이러면 라벨마다 글자 폭이 달라도 항상 정확히 8px
; 간격이 나온다 (추측 실패 위험이 없다).
; --------------------------------------------------
authorLabel :=
this.Window.AddText(
"x10 y13",
"작성자"
)
authorLabel.GetPos(
&authorLabelX,
&authorLabelY,
&authorLabelW,
&authorLabelH
)
; 2026-09: 작성자를 "관리자 ID" 목록의 표시 이름을 불러오는
; 드롭다운으로 변경 (종류 드롭다운과 같은 줄/높이).
; 목록에 없는 이름도 직접 입력할 수 있게 ComboBox를 쓴다.
; 이름이 잘리지 않게 폭을 70 -> 100으로 넓혔다.
authorChoices :=
this.GetAdminAuthorChoices()
this.AuthorInput :=
this.Window.AddComboBox(
"x"
. (authorLabelX + authorLabelW + 8)
. " y8 w100 r8",
authorChoices
)
if (authorChoices.Length > 0)
{
this.AuthorInput.Choose(1)
}
else
{
ControlSetText(
"관리자",
this.AuthorInput
)
}
this.AuthorChoicesSignature :=
this.BuildAuthorChoicesSignature(
authorChoices
)
; 펼친 목록 폭(CB_SETDROPPEDWIDTH): 긴 이름도 안 잘리게.
SendMessage(
0x160,
Round(200 * A_ScreenDPI / 96),
0,
this.AuthorInput
)
this.AuthorInput.GetPos(
&authorInputX,
&authorInputY,
&authorInputW,
&authorInputH
)
typeLabel :=
this.Window.AddText(
"x"
. (authorInputX + authorInputW + 16)
. " y13",
"종류"
)
typeLabel.GetPos(
&typeLabelX,
&typeLabelY,
&typeLabelW,
&typeLabelH
)
this.TypeDropDown :=
this.Window.AddDropDownList(
"x"
. (typeLabelX + typeLabelW + 8)
. " y8 w73 Choose2",
[
"일반",
"관리자",
"멤버",
"후원",
"공지",
"시스템",
"VIP"
]
)
this.TypeDropDown.GetPos(
&typeDropX,
&typeDropY,
&typeDropW,
&typeDropH
)
; 2026-07: Enter ON을 아래쪽 클릭송출 체크박스와
; 같은 x축에 맞춘다 (클릭송출 = authorInputX+248).
; 2026-07 순서 변경: "선택 ON/OFF"를 먼저 만들어서
; 최종 위치(오른쪽 끝 504 기준)를 먼저 확정한다.
; 그래야 바로 다음에 만드는 Enter 체크박스가 이 경계를
; 넘지 않도록(겹치면 자동으로 줄어들도록) 계산할 수 있다.
this.SelectedSendCheckBox :=
this.Window.AddCheckBox(
"x"
. (authorInputX + 400)
. " y10 Checked",
"선택 OFF"
)
this.SelectedSendCheckBox.GetPos(
&selectedCheckX,
&selectedCheckY,
&selectedCheckW,
&selectedCheckH
)
this.SelectedSendCheckBox.Move(
504 - selectedCheckW,
10
)
selectedFinalLeftEdge :=
504 - selectedCheckW
; 2026-07: Enter ON을 20px 오른쪽으로 이동.
; "선택 ON/OFF"와 겹치면 자동으로 폭을 줄인다.
enterCheckNewX :=
authorInputX + 230 + 8 + 10 + 20
this.EnterSendCheckBox :=
this.Window.AddCheckBox(
"x"
. enterCheckNewX
. " y10 w"
. Min(
105,
Max(
40,
selectedFinalLeftEdge
- 8
- enterCheckNewX
)
)
. " h22 Checked",
"Enter"
)
this.AdminMatchStatus :=
this.Window.AddText(
"x"
. authorInputX
. " y40 w"
. (
(typeDropX + typeDropW - 40)
- 8
- authorInputX
)
. " h20 +0x200",
"종류: 확인 전"
)
; 2026-07: "최근" 버튼 오른쪽 끝을 위 종류 드롭다운의
; 오른쪽 끝과 정렬한다.
this.RecentMessageButton :=
this.Window.AddButton(
"x"
. (typeDropX + typeDropW - 40)
. " y37 w40 h24",
"최근"
)
; 2026-07: 클릭송출도 Enter와 같이 20px 오른쪽으로,
; 같은 x축(enterCheckNewX)에 맞춘다. 숨기기 버튼(x414)과
; 겹치면 자동으로 폭을 줄인다.
this.SingleClickCheckBox :=
this.Window.AddCheckBox(
"x"
. enterCheckNewX
. " y38 w"
. Min(
100,
Max(
40,
414 - 8 - enterCheckNewX
)
)
. " h22 Checked",
"클릭송출"
)
this.HideButton :=
this.Window.AddButton(
"x414 y37 w90 h24",
"숨기기"
)
messageLabel :=
this.Window.AddText(
"x10 y68",
"메시지"
)
messageLabel.GetPos(
&messageLabelX,
&messageLabelY,
&messageLabelW,
&messageLabelH
)
this.MessageInput :=
this.Window.AddEdit(
"x"
. (messageLabelX + messageLabelW + 8)
. " y64 w"
. (410 - (messageLabelX + messageLabelW + 8))
. " h26"
)
this.ValidateButton :=
this.Window.AddButton(
"x414 y63 w40 h26",
"검사"
)
this.SendButton :=
this.Window.AddButton(
"x458 y63 w46 h26",
"송출"
)
categoryLabel :=
this.Window.AddText(
"x10 y100",
"항 목"
)
categoryLabel.GetPos(
&categoryLabelX,
&categoryLabelY,
&categoryLabelW,
&categoryLabelH
)
; 2026-07: 항목 리스트박스(CategoryCombo)를 라벨 폭과
; 무관하게, 위에 있는 메시지 입력창과 같은 x축(입력창 시작
; 위치)에 맞춘다.
this.CategoryCombo :=
this.Window.AddComboBox(
"x"
. (messageLabelX + messageLabelW + 8)
. " y96 w90"
)
this.CategoryCombo.GetPos(
&categoryComboX,
&categoryComboY,
&categoryComboW,
&categoryComboH
)
nameLabel :=
this.Window.AddText(
"x"
. (categoryComboX + categoryComboW + 16)
. " y100",
"이 름"
)
nameLabel.GetPos(
&nameLabelX,
&nameLabelY,
&nameLabelW,
&nameLabelH
)
this.NameInput :=
this.Window.AddEdit(
"x"
. (nameLabelX + nameLabelW + 8)
. " y96 w70 h22"
)
this.NameInput.GetPos(
&nameInputX,
&nameInputY,
&nameInputW,
&nameInputH
)
this.RegisterButton :=
this.Window.AddButton(
"x"
. (nameInputX + nameInputW + 8)
. " y94 w40 h26",
"등록"
)
this.DeleteButton :=
this.Window.AddButton(
"x"
. (nameInputX + nameInputW + 8 + 40 + 6)
. " y94 w40 h26",
"삭제"
)
this.SendSelectedButton :=
this.Window.AddButton(
"x414 y94 w90 h26",
"선택송출"
)
this.MacroList :=
this.Window.AddListView(
"x10 y126 w494 h110 Grid -Multi",
[
"ID",
"매크로 이름",
"종류",
"작성자",
"메시지"
]
)
this.MacroList.ModifyCol(1, 0)
this.MacroList.ModifyCol(2, 90)
this.MacroList.ModifyCol(3, 60)
this.MacroList.ModifyCol(4, 65)
this.MacroList.ModifyCol(5, 264)
this.ClearButton :=
this.Window.AddButton(
"x10 y244 w50 h28",
"지우기"
)
this.CategoryDeleteButton :=
this.Window.AddButton(
"x76 y244 w66 h28",
"항목삭제"
)
visibleLimitLabel :=
this.Window.AddText(
"x158 y248",
"표시"
)
visibleLimitLabel.GetPos(
&visibleLimitLabelX,
&visibleLimitLabelY,
&visibleLimitLabelW,
&visibleLimitLabelH
)
this.VisibleLimitInput :=
this.Window.AddEdit(
"x"
. (visibleLimitLabelX + visibleLimitLabelW + 8)
. " y244 w38 h28 Number Center",
this.Overlay.GetVisibleMessageLimit()
)
this.VisibleLimitApplyButton :=
this.Window.AddButton(
"x"
. (visibleLimitLabelX + visibleLimitLabelW + 8 + 38 + 6)
. " y244 w42 h28",
"적용"
)
this.AdminIdentityButton :=
this.Window.AddButton(
"x298 y244 w66 h28",
"관리자ID"
)
this.LogWindowButton :=
this.Window.AddButton(
"x370 y244 w134 h28",
"로그창"
)
; --------------------------------------------------
; 유튜브 라이브 연동 (상태 메시지와 자리를 맞바꾼 상태)
; --------------------------------------------------
liveUrlLabel :=
this.Window.AddText(
"x10 y284",
"라이브URL"
)
liveUrlLabel.GetPos(
&liveUrlLabelX,
&liveUrlLabelY,
&liveUrlLabelW,
&liveUrlLabelH
)
this.LiveUrlInput :=
this.Window.AddEdit(
"x"
. (liveUrlLabelX + liveUrlLabelW + 8)
. " y280 w"
. (504 - (liveUrlLabelX + liveUrlLabelW + 8))
. " h26"
)
; 2026-07 이동: 연결/연결해제/상태는 이제 URL 줄이 아니라
; 송출완료 메시지 줄로 내려서, "로그창" 버튼(x370~504)과
; 오른쪽 끝(504)이 맞도록 배치한다.
this.StatusText :=
this.Window.AddText(
"x10 y318 w240 h30",
"매크로를 등록 또는 선택하세요."
)
this.ConnectLiveButton :=
this.Window.AddButton(
"x258 y317 w50 h28",
"연결"
)
this.DisconnectLiveButton :=
this.Window.AddButton(
"x314 y317 w70 h28",
"연결해제"
)
this.DisconnectLiveButton.Enabled :=
false
this.LiveStatusText :=
this.Window.AddText(
"x392 y318 w112 h36 Right",
"연결 안 됨"
)
}
ConnectEvents()
{
this.AuthorInput.OnEvent(
"Change",
ObjBindMethod(
this,
"InvalidateValidation"
)
)
this.AuthorInput.OnEvent(
"Change",
ObjBindMethod(
this,
"HandleAuthorChanged"
)
)
this.TypeDropDown.OnEvent(
"Change",
ObjBindMethod(
this,
"InvalidateValidation"
)
)
this.MessageInput.OnEvent(
"Change",
ObjBindMethod(
this,
"InvalidateValidation"
)
)
this.RecentMessageButton.OnEvent(
"Click",
ObjBindMethod(
this,
"ShowMessageHistoryDialog"
)
)
this.EnterSendCheckBox.OnEvent(
"Click",
ObjBindMethod(
this,
"HandleEnterModeChanged"
)
)
this.SelectedSendCheckBox.OnEvent(
"Click",
ObjBindMethod(
this,
"HandleSelectedSendModeChanged"
)
)
this.CategoryCombo.OnEvent(
"Change",
ObjBindMethod(
this,
"RefreshMacroList"
)
)
this.ValidateButton.OnEvent(
"Click",
ObjBindMethod(
this,
"ValidateMessage"
)
)
this.SendButton.OnEvent(
"Click",
ObjBindMethod(
this,
"SubmitManualMessage"
)
)
this.ConnectLiveButton.OnEvent(
"Click",
ObjBindMethod(
this,
"StartLiveConnection"
)
)
this.HideButton.OnEvent(
"Click",
ObjBindMethod(
this,
"HideToTray"
)
)
this.DisconnectLiveButton.OnEvent(
"Click",
ObjBindMethod(
this,
"StopLiveConnection"
)
)
this.RegisterButton.OnEvent(
"Click",
ObjBindMethod(
this,
"RegisterCurrentMacro"
)
)
this.DeleteButton.OnEvent(
"Click",
ObjBindMethod(
this,
"DeleteSelectedMacro"
)
)
this.SendSelectedButton.OnEvent(
"Click",
ObjBindMethod(
this,
"SendSelectedMacro"
)
)
this.MacroList.OnEvent(
"Click",
ObjBindMethod(
this,
"HandleMacroListClick"
)
)
this.ClearButton.OnEvent(
"Click",
ObjBindMethod(
this,
"ClearOverlay"
)
)
this.CategoryDeleteButton.OnEvent(
"Click",
ObjBindMethod(
this,
"DeleteSelectedCategory"
)
)
this.VisibleLimitApplyButton.OnEvent(
"Click",
ObjBindMethod(
this,
"ApplyVisibleLimit"
)
)
this.AdminIdentityButton.OnEvent(
"Click",
ObjBindMethod(
this,
"ShowAdminIdentityWindow"
)
)
this.LogWindowButton.OnEvent(
"Click",
ObjBindMethod(
this,
"ShowLogWindow"
)
)
this.Window.OnEvent(
"Close",
ObjBindMethod(
this,
"HandleClose"
)
)
}
Show()
{
this.Window.Show(
"x930 y30 w514 h360"
)
this.RefreshCategoryChoices(
"전체"
)
this.UpdateEnterModeUi()
this.UpdateSelectedSendModeUi()
this.UpdateAdminIdentityStatus()
this.MessageInput.Focus()
}
HandleClose(*)
{
this.ExitCallback.Call()
}
HandleEnterKey()
{
; 작성자/카테고리/매크로 이름 등 다른 입력폼에서는
; Enter를 송출 키로 사용하지 않는다.
if (!this.IsMessageInputFocused())
{
return
}
; 같은 Enter가 중복 예약되는 것을 막는다.
if (this.EnterPending)
{
return
}
this.EnterPending := true
; KeyUp 뒤 한글 IME가 마지막 조합 글자를 Edit에 확정할
; 시간을 준 다음 메시지 값을 읽는다.
SetTimer(
ObjBindMethod(
this,
"ProcessDeferredEnter"
),
-30
)
}
ProcessDeferredEnter()
{
this.EnterPending := false
if (
!IsObject(this.Window)
|| !WinExist(
"ahk_id "
. this.Window.Hwnd
)
|| !this.IsMessageInputFocused()
)
{
return
}
if (this.EnterSendCheckBox.Value)
{
this.SubmitManualMessage()
return
}
this.StatusText.Value :=
"Enter OFF: 검사 눌러야 송출가능"
this.MessageInput.Focus()
}
IsMessageInputFocused()
{
focusedControlHandle :=
DllCall(
"user32\GetFocus",
"Ptr"
)
return (
focusedControlHandle
= this.MessageInput.Hwnd
)
}
RefreshCategoryChoices(
preferredCategory := "전체"
)
{
macroObjects :=
this.Repository.LoadAll()
categorySeen := Map()
categories := []
for chatMacroObject in macroObjects
{
if (
!categorySeen.Has(
chatMacroObject.Category
)
)
{
categorySeen[
chatMacroObject.Category
] := true
categories.Push(
chatMacroObject.Category
)
}
}
this.CategoryCombo.Delete()
this.CategoryCombo.Add(
["전체"]
)
for categoryText in categories
{
this.CategoryCombo.Add(
[categoryText]
)
}
selectedIndex := 1
if (preferredCategory != "전체")
{
for categoryIndex, categoryText in categories
{
if (
categoryText
= preferredCategory
)
{
selectedIndex :=
categoryIndex + 1
break
}
}
}
this.CategoryCombo.Choose(
selectedIndex
)
this.RefreshMacroList()
}
RefreshMacroList(*)
{
selectedCategory :=
Trim(
this.CategoryCombo.Text
)
if (selectedCategory = "")
{
selectedCategory := "전체"
}
this.MacroList.Delete()
macroObjects :=
this.Repository.LoadAll()
for chatMacroObject in macroObjects
{
if (
selectedCategory != "전체"
&& chatMacroObject.Category
!= selectedCategory
)
{
continue
}
typeLabel :=
this.TypeLabelMap.Has(
chatMacroObject.MessageType
)
? this.TypeLabelMap[
chatMacroObject.MessageType
]
: chatMacroObject.MessageType
this.MacroList.Add(
"",
chatMacroObject.Id,
chatMacroObject.Name,
typeLabel,
chatMacroObject.Author,
chatMacroObject.Text
)
}
}
HandleEnterModeChanged(*)
{
this.InvalidateValidation()
this.UpdateEnterModeUi()
}
HandleSelectedSendModeChanged(*)
{
this.UpdateSelectedSendModeUi(
true
)
}
UpdateSelectedSendModeUi(
updateStatus := false
)
{
if (this.SelectedSendCheckBox.Value)
{
this.SelectedSendCheckBox.Text :=
"선택 ON"
this.SendSelectedButton.Enabled :=
true
if (updateStatus)
{
this.StatusText.Value :=
"선택송출 ON: 선택송출 활성"
}
return
}
this.SelectedSendCheckBox.Text :=
"선택 OFF"
this.SendSelectedButton.Enabled :=
false
if (updateStatus)
{
this.StatusText.Value :=
"선택송출 OFF: 선택송출 비활성"
}
}
UpdateEnterModeUi()
{
if (this.EnterSendCheckBox.Value)
{
this.EnterSendCheckBox.Text :=
"Enter ON"
this.SendButton.Enabled :=
true
this.StatusText.Value :=
"Enter ON: Enter로 송출"
return
}
this.EnterSendCheckBox.Text :=
"Enter OFF"
this.SendButton.Enabled :=
this.ValidationPassed
this.StatusText.Value :=
"Enter OFF: 검사 눌러야 송출 활성"
}
InvalidateValidation(*)
{
this.ValidationPassed := false
if (!this.EnterSendCheckBox.Value)
{
this.SendButton.Enabled := false
this.StatusText.Value :=
"검사를 다시 누르세요."
}
}
ValidateMessage(*)
{
authorText :=
Trim(
this.AuthorInput.Text
)
if (authorText = "")
{
this.ValidationPassed := false
this.SendButton.Enabled :=
this.EnterSendCheckBox.Value
this.StatusText.Value :=
"작성자를 입력하세요."
this.AuthorInput.Focus()
return false
}
validationResult :=
this.Validator.Validate(
this.MessageInput.Value
)
if (!validationResult["IsValid"])
{
this.ValidationPassed := false
this.SendButton.Enabled :=
this.EnterSendCheckBox.Value
this.StatusText.Value :=
"검사 실패: "
. validationResult["Message"]
this.MessageInput.Focus()
return false
}
this.MessageInput.Value :=
validationResult[
"NormalizedText"
]
this.ValidationPassed := true
this.SendButton.Enabled := true
this.StatusText.Value :=
validationResult[
"Message"
]
this.MessageInput.Focus()
return true
}
SubmitManualMessage(*)
{
if (
!this.EnterSendCheckBox.Value
&& !this.ValidationPassed
)
{
this.StatusText.Value :=
"Enter OFF. 검사를 누르세요."
this.MessageInput.Focus()
return
}
authorText :=
Trim(
this.AuthorInput.Text
)
messageText :=
Trim(
this.MessageInput.Value
)
if (authorText = "")
{
this.StatusText.Value :=
"작성자를 입력하세요."
this.AuthorInput.Focus()
return
}
if (messageText = "")
{
this.StatusText.Value :=
"메시지를 입력하세요."
this.MessageInput.Focus()
return
}
selectedTypeLabel :=
this.TypeDropDown.Text
if (
!this.TypeMap.Has(
selectedTypeLabel
)
)
{
this.StatusText.Value :=
"메시지 종류를 선택하세요."
return
}
selectedMessageType :=
this.TypeMap[
selectedTypeLabel
]
resolvedMessageType :=
this.ResolveMessageType(
authorText,
selectedMessageType
)
visibleCount :=
this.Overlay.AddMessage(
authorText,
messageText,
resolvedMessageType
)
this.RecordMessageHistory(
messageText
)
this.MessageInput.Value := ""
this.ValidationPassed := false
this.SendButton.Enabled :=
this.EnterSendCheckBox.Value
this.StatusText.Value :=
"송출완료 / 메시지 "
. visibleCount
. "개"
. (
resolvedMessageType = "Admin"
&& selectedMessageType != "Admin"
? " / 종류"
: ""
)
this.WriteLog(
"수동 송출",
authorText
. " | "
. this.GetTypeLabel(
resolvedMessageType
)
. " | "
. messageText
)
this.MessageInput.Focus()
}
RegisterCurrentMacro(*)
{
categoryText :=
Trim(
this.CategoryCombo.Text
)
if (
categoryText = ""
|| categoryText = "전체"
)
{
this.StatusText.Value :=
"카테고리 이름을 입력하세요."
this.CategoryCombo.Focus()
return
}
macroName :=
Trim(
this.NameInput.Value
)
if (macroName = "")
{
this.StatusText.Value :=
"매크로 이름을 입력하세요."
this.NameInput.Focus()
return
}
authorText :=
Trim(
this.AuthorInput.Text
)
if (authorText = "")
{
this.StatusText.Value :=
"작성자를 입력하세요."
this.AuthorInput.Focus()
return
}
validationResult :=
this.Validator.Validate(
this.MessageInput.Value
)
if (!validationResult["IsValid"])
{
this.StatusText.Value :=
"등록 실패: "
. validationResult["Message"]
this.MessageInput.Focus()
return
}
selectedTypeLabel :=
this.TypeDropDown.Text
if (
!this.TypeMap.Has(
selectedTypeLabel
)
)
{
this.StatusText.Value :=
"메시지 종류를 선택하세요."
return
}
chatMacroObject :=
ChatMacro(
this.Repository.CreateId(),
categoryText,
macroName,
authorText,
this.TypeMap[
selectedTypeLabel
],
validationResult[
"NormalizedText"
]
)
this.Repository.AddMacro(
chatMacroObject
)
this.NameInput.Value := ""
this.RefreshCategoryChoices(
categoryText
)
this.StatusText.Value :=
"매크로 등록 완료: "
. chatMacroObject.Name
this.WriteLog(
"매크로 등록",
categoryText
. " | "
. chatMacroObject.Name
)
this.MessageInput.Focus()
}
DeleteSelectedMacro(*)
{
selectedRow :=
this.MacroList.GetNext(
0
)
if (selectedRow = 0)
{
this.StatusText.Value :=
"삭제할 매크로를 먼저 선택하세요."
return
}
macroId :=
this.MacroList.GetText(
selectedRow,
1
)
macroName :=
this.MacroList.GetText(
selectedRow,
2
)
if (
this.Repository.DeleteById(
macroId
)
)
{
this.RefreshMacroList()
this.StatusText.Value :=
"매크로 삭제 완료: "
. macroName
this.WriteLog(
"매크로 삭제",
macroName
)
return
}
this.StatusText.Value :=
"매크로 삭제에 실패했습니다."
}
HandleMacroListClick(
listControl,
rowNumber,
*
)
{
if (
rowNumber <= 0
|| !this.SingleClickCheckBox.Value
)
{
return
}
this.SendMacroRow(
rowNumber
)
}
SendSelectedMacro(*)
{
if (!this.SelectedSendCheckBox.Value)
{
this.StatusText.Value :=
"선택 송출이 OFF입니다."
return
}
selectedRow :=
this.MacroList.GetNext(
0
)
if (selectedRow = 0)
{
this.StatusText.Value :=
"송출할 매크로를 먼저 선택하세요."
return
}
this.SendMacroRow(
selectedRow
)
}
SendMacroRow(rowNumber)
{
macroName :=
this.MacroList.GetText(
rowNumber,
2
)
typeLabel :=
this.MacroList.GetText(
rowNumber,
3
)
authorText :=
this.MacroList.GetText(
rowNumber,
4
)
messageText :=
this.MacroList.GetText(
rowNumber,
5
)
if (!this.TypeMap.Has(typeLabel))
{
this.StatusText.Value :=
"매크로를 찾을 수 없습니다."
return
}
validationResult :=
this.Validator.Validate(
messageText
)
if (!validationResult["IsValid"])
{
this.StatusText.Value :=
"매크로 송출 실패: "
. validationResult["Message"]
return
}
selectedMessageType :=
this.TypeMap[
typeLabel
]
resolvedMessageType :=
this.ResolveMessageType(
authorText,
selectedMessageType
)
visibleCount :=
this.Overlay.AddMessage(
authorText,
validationResult[
"NormalizedText"
],
resolvedMessageType
)
this.StatusText.Value :=
"송출완료: "
. macroName
. " / 메시지 "
. visibleCount
. "개"
. (
resolvedMessageType = "Admin"
&& selectedMessageType != "Admin"
? " / 종류"
: ""
)
this.WriteLog(
"매크로 송출",
macroName
. " | "
. authorText
. " | "
. this.GetTypeLabel(
resolvedMessageType
)
. " | "
. messageText
)
}
DeleteSelectedCategory(*)
{
if (
IsObject(this.DeleteDialog)
&& WinExist(
"ahk_id "
. this.DeleteDialog.Hwnd
)
)
{
this.DeleteDialog.Show()
this.ActivateWindow(
this.DeleteDialog.Hwnd
)
return
}
categoryText :=
Trim(
this.CategoryCombo.Text
)
if (
categoryText = ""
|| categoryText = "전체"
)
{
this.StatusText.Value :=
"'전체'는 삭제 불가합니다."
this.CategoryCombo.Focus()
return
}
macroCount :=
this.Repository.CountByCategory(
categoryText
)
if (macroCount <= 0)
{
this.StatusText.Value :=
"저장된 매크로가 없습니다."
this.CategoryCombo.Focus()
return
}
this.ShowCategoryDeleteDialog(
categoryText,
macroCount
)
}
ShowCategoryDeleteDialog(
categoryText,
macroCount
)
{
this.PendingDeleteCategory :=
categoryText
this.PendingDeleteMacroCount :=
macroCount
this.DeleteDialog :=
Gui(
"+AlwaysOnTop +ToolWindow -MinimizeBox -MaximizeBox"
. " +Owner"
. this.Window.Hwnd,
"카테고리 삭제 확인"
)
this.DeleteDialog.SetFont(
"s10",
"맑은 고딕"
)
this.DeleteDialog.AddText(
"x25 y22 w390 h70 Center",
"카테고리 ["
. categoryText
. "]의 매크로 "
. macroCount
. "개가 모두 삭제됩니다."
. "`n`n계속하시겠습니까?"
)
confirmButton :=
this.DeleteDialog.AddButton(
"x95 y112 w100 h34",
"삭제"
)
cancelButton :=
this.DeleteDialog.AddButton(
"x225 y112 w100 h34 Default",
"취소"
)
confirmButton.OnEvent(
"Click",
ObjBindMethod(
this,
"ConfirmCategoryDelete"
)
)
cancelButton.OnEvent(
"Click",
ObjBindMethod(
this,
"CancelCategoryDelete"
)
)
this.DeleteDialog.OnEvent(
"Close",
ObjBindMethod(
this,
"CancelCategoryDelete"
)
)
this.DeleteDialog.OnEvent(
"Escape",
ObjBindMethod(
this,
"CancelCategoryDelete"
)
)
DllCall(
"EnableWindow",
"Ptr", this.Window.Hwnd,
"Int", false,
"Int"
)
adminX := 0
adminY := 0
adminWidth := 0
adminHeight := 0
this.Window.GetPos(
&adminX,
&adminY,
&adminWidth,
&adminHeight
)
dialogWidth := 420
dialogHeight := 175
dialogX :=
adminX
+ Floor(
(
adminWidth
- dialogWidth
)
/ 2
)
dialogY :=
adminY
+ Floor(
(
adminHeight
- dialogHeight
)
/ 2
)
this.DeleteDialog.Show(
"x"
. dialogX
. " y"
. dialogY
. " w"
. dialogWidth
. " h"
. dialogHeight
)
this.ActivateWindow(
this.DeleteDialog.Hwnd
)
}
ConfirmCategoryDelete(*)
{
categoryText :=
this.PendingDeleteCategory
deletedCount :=
this.Repository.DeleteByCategory(
categoryText
)
this.CloseCategoryDeleteDialog()
if (deletedCount <= 0)
{
this.StatusText.Value :=
"카테고리 삭제에 실패했습니다."
return
}
this.RefreshCategoryChoices(
"전체"
)
this.StatusText.Value :=
"카테고리 삭제 완료: "
. categoryText
. " / 매크로 "
. deletedCount
. "개 삭제"
this.WriteLog(
"카테고리 삭제",
categoryText
. " | "
. deletedCount
. "개"
)
}
CancelCategoryDelete(*)
{
this.CloseCategoryDeleteDialog()
this.StatusText.Value :=
"카테고리 삭제를 취소했습니다."
}
CloseCategoryDeleteDialog()
{
if (
IsObject(this.DeleteDialog)
&& WinExist(
"ahk_id "
. this.DeleteDialog.Hwnd
)
)
{
this.DeleteDialog.Destroy()
}
this.DeleteDialog := 0
this.PendingDeleteCategory := ""
this.PendingDeleteMacroCount := 0
if (
IsObject(this.Window)
&& WinExist(
"ahk_id "
. this.Window.Hwnd
)
)
{
DllCall(
"EnableWindow",
"Ptr", this.Window.Hwnd,
"Int", true,
"Int"
)
SetTimer(
ObjBindMethod(
this,
"RestoreWindow"
),
-30
)
}
}
RestoreWindow()
{
if (
!IsObject(this.Window)
|| !WinExist(
"ahk_id "
. this.Window.Hwnd
)
)
{
return
}
this.ActivateWindow(
this.Window.Hwnd
)
}
ActivateWindow(windowHandle)
{
if (!windowHandle)
{
return
}
DllCall(
"SetWindowPos",
"Ptr", windowHandle,
"Ptr", -1,
"Int", 0,
"Int", 0,
"Int", 0,
"Int", 0,
"UInt", 0x43,
"Int"
)
DllCall(
"ShowWindow",
"Ptr", windowHandle,
"Int", 5,
"Int"
)
DllCall(
"SetForegroundWindow",
"Ptr", windowHandle,
"Int"
)
}
ApplyVisibleLimit(*)
{
rawVisibleLimit :=
Trim(
this.VisibleLimitInput.Value
)
bufferCapacity :=
this.Overlay.GetBufferCapacity()
if (
rawVisibleLimit = ""
|| !RegExMatch(
rawVisibleLimit,
"^\d+$"
)
)
{
this.StatusText.Value :=
"표시는 1~"
. bufferCapacity
. "개 사이 가능"
this.VisibleLimitInput.Focus()
return
}
visibleLimit :=
rawVisibleLimit + 0
if (
visibleLimit < 1
|| visibleLimit
> bufferCapacity
)
{
this.StatusText.Value :=
"표시 개수 범위: 1~"
. bufferCapacity
this.VisibleLimitInput.Focus()
return
}
actualRenderedCount :=
this.Overlay.SetVisibleMessageLimit(
visibleLimit
)
IniWrite(
visibleLimit,
this.OverlaySettingsPath,
"Messages",
"VisibleMessageLimit"
)
this.VisibleLimitInput.Value :=
visibleLimit
this.StatusText.Value :=
"표시 "
. visibleLimit
. "개/현재 "
. actualRenderedCount
. "개/저장 완료"
this.WriteLog(
"표시 개수",
visibleLimit
. "개 적용"
)
this.VisibleLimitInput.Focus()
}
ClearOverlay(*)
{
this.Overlay.ClearMessages()
this.StatusText.Value :=
"오버레이 메시지를 모두 지웠습니다."
this.WriteLog(
"화면 지우기",
"오버레이 메시지 전체 삭제"
)
this.MessageInput.Focus()
}
; 관리자 ID 목록에서 표시 이름만 뽑아 드롭다운 항목으로 쓴다.
GetAdminAuthorChoices()
{
choices := []
seenNames := Map()
identityObjects :=
this.AdminIdentityRepository.LoadAll()
for identityObject in identityObjects
{
displayName :=
Trim(
identityObject[
"DisplayName"
]
)
if (displayName = "")
{
continue
}
nameKey := StrLower(displayName)
if (seenNames.Has(nameKey))
{
continue
}
seenNames[nameKey] := true
choices.Push(displayName)
}
return choices
}
BuildAuthorChoicesSignature(choices)
{
signatureText := ""
for choiceText in choices
{
signatureText .= choiceText . "`n"
}
return signatureText
}
; 관리자 ID가 추가/삭제되어 목록이 실제로 바뀐 때만
; 드롭다운 항목을 다시 채우고, 입력칸의 글자는 그대로 유지한다.
RefreshAuthorChoicesIfChanged()
{
choices :=
this.GetAdminAuthorChoices()
signatureText :=
this.BuildAuthorChoicesSignature(
choices
)
if (signatureText = this.AuthorChoicesSignature)
{
return
}
this.AuthorChoicesSignature :=
signatureText
currentText :=
this.AuthorInput.Text
this.AuthorInput.Delete()
if (choices.Length > 0)
{
this.AuthorInput.Add(
choices
)
}
if (this.AuthorInput.Text != currentText)
{
ControlSetText(
currentText,
this.AuthorInput
)
}
}
; 목록에서 항목을 고르는 순간에는 입력칸 글자가 아직
; 바뀌기 전일 수 있어서, 잠깐 뒤에 관리자 여부를 판정한다.
HandleAuthorChanged(*)
{
if (!this.AuthorStatusTimerBound)
{
this.AuthorStatusTimerBound :=
ObjBindMethod(
this,
"UpdateAdminIdentityStatus"
)
}
SetTimer(
this.AuthorStatusTimerBound,
-30
)
}
UpdateAdminIdentityStatus(*)
{
this.RefreshAuthorChoicesIfChanged()
authorText :=
Trim(
this.AuthorInput.Text
)
if (authorText = "")
{
this.AdminMatchStatus.Value :=
"종류: 작성자 없음"
return false
}
if (
this.AdminIdentityRepository.IsAdministrator(
"",
authorText
)
)
{
this.AdminMatchStatus.Value :=
"종류: 관리자 일치"
return true
}
this.AdminMatchStatus.Value :=
"종류: 일반 작성자"
return false
}
ResolveMessageType(
authorText,
selectedMessageType
)
{
; 관리자 여부는 작성자 판정 상태로만 보관한다.
; 본문 색상은 사용자가 선택한 일반/후원/VIP/공지
; 메시지 종류를 그대로 유지한다.
if (
this.AdminIdentityRepository.IsAdministrator(
"",
authorText
)
)
{
this.AdminMatchStatus.Value :=
"종류: 관리자 일치"
return selectedMessageType
}
this.AdminMatchStatus.Value :=
"종류: 일반 작성자"
return selectedMessageType
}
GetTypeLabel(messageType)
{
return this.TypeLabelMap.Has(
messageType
)
? this.TypeLabelMap[
messageType
]
: messageType
}
ShowAdminIdentityWindow(*)
{
this.AdminIdentityWindow.Show()
}
ShowLogWindow(*)
{
this.LogWindow.Show()
}
WriteLog(
eventType,
details
)
{
this.LogWindow.AddEvent(
eventType,
details
)
}
; ------------------------------------------------------------
; 유튜브 라이브 연동 (2026-07 추가)
;
; 설계 원칙:
; - 이 메서드들은 YoutubeProvider(순수 네트워크/파싱 로직)만
; 호출하고, 그 결과를 기존 렌더링 파이프라인
; (this.Overlay.AddMessage)에 그대로 넘긴다.
; - 폴링 간격은 유튜브 API가 알려주는 값을 따르되,
; 최소 2000ms를 보장해 할당량을 아낀다.
; - HTTP 요청은 YoutubeProvider 내부에서 5초 타임아웃으로
; 제한되어 있어, 요청 중 GUI가 무한정 멈추지 않는다.
; ------------------------------------------------------------
StartLiveConnection(*)
{
if (Trim(this.YoutubeApiKey) = "")
{
this.LiveStatusText.Value :=
"API 키 미설정"
MsgBox(
"유튜브 API 키가 설정되어 있지 않습니다.`n"
. "Data\Settings\YoutubeApiKey.ini 파일을 확인하세요.",
"라이브 연동",
"Iconx"
)
return
}
liveUrlText :=
Trim(this.LiveUrlInput.Value)
if (liveUrlText = "")
{
this.LiveStatusText.Value :=
"URL을 입력하세요"
return
}
this.ConnectLiveButton.Enabled := false
this.LiveStatusText.Value := "연결 중..."
try
{
this.YoutubeProviderInstance :=
YoutubeProvider(
this.YoutubeApiKey
)
videoId :=
this.YoutubeProviderInstance.ExtractVideoId(
liveUrlText
)
connectResult :=
this.YoutubeProviderInstance.ResolveLiveChatId(
videoId
)
this.LiveStatusText.Value :=
"연결됨"
this.DisconnectLiveButton.Enabled := true
this.LivePollFailureCount := 0
this.LogWindow.AddEvent(
"유튜브 연동",
"연결됨: "
. connectResult["Title"]
)
; 첫 폴링 응답에는 연결 이전에 이미 쌓여있던
; 채팅 기록이 한꺼번에 들어있다.
; 이걸 화면에 그대로 뿌리면 "예전 채팅이 쏟아지는"
; 현상이 생기므로, 한 번은 결과를 버리고
; pageToken(현재 위치)만 확보해둔다.
; 이후 폴링부터는 이 시점 다음에 온 메시지만 온다.
this.YoutubeProviderInstance.Poll()
this.LivePollBound :=
ObjBindMethod(
this,
"RunLivePoll"
)
SetTimer(
this.LivePollBound,
-100
)
}
catch as errorObject
{
this.LiveStatusText.Value :=
"연결 실패"
this.ConnectLiveButton.Enabled := true
this.YoutubeProviderInstance := 0
this.LogWindow.AddEvent(
"유튜브 연동 오류",
errorObject.Message
)
MsgBox(
errorObject.Message,
"라이브 연동 실패",
"Iconx"
)
}
}
RunLivePoll()
{
if (!IsObject(this.YoutubeProviderInstance))
{
return
}
try
{
pollResult :=
this.YoutubeProviderInstance.Poll()
; 성공하면 실패 횟수를 0으로 되돌린다.
this.LivePollFailureCount := 0
for incomingChatMessage in pollResult["Messages"]
{
this.HandleIncomingLiveMessage(
incomingChatMessage
)
}
nextIntervalMs :=
pollResult["PollingIntervalMillis"]
; 2026-07 조정: 2초는 하루 종일(8시간+) 방송 시
; 할당량(1일 10,000유닛)을 넘길 위험이 있어서
; 6초로 늘렸다. 채팅이 화면에 뜨는 속도는
; 1~2초 정도만 더 느려지는 수준이다.
if (nextIntervalMs < 6000)
{
nextIntervalMs := 6000
}
SetTimer(
this.LivePollBound,
-nextIntervalMs
)
}
catch as errorObject
{
; 2026-07 추가: 실패가 반복될수록(예: 할당량 초과로
; 계속 403이 나는 상황) 재시도 간격을 점점 늘린다.
; 실패한 요청도 최소 1유닛은 소모되므로,
; 5초마다 무한정 재시도하면 이미 바닥난 할당량을
; 계속 갉아먹거나 다음 날 몫까지 낭비할 수 있다.
;
; 5초 -> 10초 -> 20초 -> ... -> 최대 5분까지 늘어난다.
this.LivePollFailureCount += 1
backoffMs :=
5000
* (2 ** Min(this.LivePollFailureCount - 1, 6))
if (backoffMs > 300000)
{
backoffMs := 300000
}
this.LiveStatusText.Value :=
"오류, "
. Round(backoffMs / 1000)
. "초 후 재시도"
this.LogWindow.AddEvent(
"유튜브 폴링 오류",
errorObject.Message
. " (연속 실패 "
. this.LivePollFailureCount
. "회, 다음 재시도까지 "
. Round(backoffMs / 1000)
. "초)"
)
SetTimer(
this.LivePollBound,
-backoffMs
)
}
}
HandleIncomingLiveMessage(incomingChatMessage)
{
resolvedType :=
incomingChatMessage["Type"]
if (
this.AdminIdentityRepository.IsAdministrator(
incomingChatMessage["ChannelId"],
incomingChatMessage["Author"]
)
)
{
resolvedType := "Admin"
}
this.Overlay.AddMessage(
incomingChatMessage["Author"],
incomingChatMessage["Message"],
resolvedType
)
this.LogWindow.AddEvent(
incomingChatMessage["Author"],
incomingChatMessage["Message"]
)
}
StopLiveConnection(*)
{
if (IsObject(this.LivePollBound))
{
SetTimer(
this.LivePollBound,
0
)
}
this.YoutubeProviderInstance := 0
this.ConnectLiveButton.Enabled := true
this.DisconnectLiveButton.Enabled := false
this.LiveStatusText.Value := "연결 해제됨"
this.LogWindow.AddEvent(
"유튜브 연동",
"연결 해제"
)
}
; 2026-07 추가: OS 최소화 버튼 대체용.
; 창을 숨기고, 트레이 아이콘 더블클릭(또는 우클릭 메뉴)으로
; 다시 불러올 수 있다.
HideToTray(*)
{
this.Window.Hide()
}
; 2026-07 재작성: 메시지 기록 목록을 별도 팝업 창(DeleteDialog와
; 같은 검증된 구조)으로 보여준다. "최근" 버튼을 눌러야 뜨고,
; 목록에서 하나 골라 "선택" 버튼을 눌러야 입력창에 채워진다.
; RecordMessageHistory: 실제로 메시지를 보낼 때마다
; 이 함수로 기록해둔다 (최대 10개, 최신이 맨 위).
ShowMessageHistoryDialog(*)
{
if (
IsObject(this.HistoryDialog)
&& WinExist(
"ahk_id "
. this.HistoryDialog.Hwnd
)
)
{
this.HistoryDialog.Show()
this.ActivateWindow(
this.HistoryDialog.Hwnd
)
return
}
if (this.MessageHistory.Length = 0)
{
this.StatusText.Value :=
"아직 기록된 메시지가 없습니다."
return
}
this.HistoryDialog :=
Gui(
"+AlwaysOnTop +ToolWindow -MinimizeBox -MaximizeBox"
. " +Owner"
. this.Window.Hwnd,
"최근 메시지"
)
this.HistoryDialog.SetFont(
"s10",
"맑은 고딕"
)
historyListBox :=
this.HistoryDialog.AddListBox(
"x20 y20 w380 h320 -Multi"
)
for historyItem in this.MessageHistory
{
historyListBox.Add(
[historyItem]
)
}
historyListBox.Choose(1)
applyButton :=
this.HistoryDialog.AddButton(
"x95 y352 w100 h34 Default",
"선택"
)
cancelButton :=
this.HistoryDialog.AddButton(
"x225 y352 w100 h34",
"취소"
)
applyButton.OnEvent(
"Click",
ObjBindMethod(
this,
"ApplyMessageHistorySelection",
historyListBox
)
)
historyListBox.OnEvent(
"DoubleClick",
ObjBindMethod(
this,
"ApplyMessageHistorySelection",
historyListBox
)
)
cancelButton.OnEvent(
"Click",
ObjBindMethod(
this,
"CancelMessageHistoryDialog"
)
)
this.HistoryDialog.OnEvent(
"Close",
ObjBindMethod(
this,
"CancelMessageHistoryDialog"
)
)
this.HistoryDialog.OnEvent(
"Escape",
ObjBindMethod(
this,
"CancelMessageHistoryDialog"
)
)
DllCall(
"EnableWindow",
"Ptr", this.Window.Hwnd,
"Int", false,
"Int"
)
adminX := 0
adminY := 0
adminWidth := 0
adminHeight := 0
this.Window.GetPos(
&adminX,
&adminY,
&adminWidth,
&adminHeight
)
dialogWidth := 420
dialogHeight := 415
dialogX :=
adminX
+ Floor(
(
adminWidth
- dialogWidth
)
/ 2
)
- 5
dialogY :=
adminY
+ Floor(
(
adminHeight
- dialogHeight
)
/ 2
)
- 8
this.HistoryDialog.Show(
"x"
. dialogX
. " y"
. dialogY
. " w"
. dialogWidth
. " h"
. dialogHeight
)
this.ActivateWindow(
this.HistoryDialog.Hwnd
)
}
ApplyMessageHistorySelection(historyListBox, *)
{
selectedText :=
historyListBox.Text
if (selectedText != "")
{
this.MessageInput.Value :=
selectedText
this.InvalidateValidation()
}
this.CloseMessageHistoryDialog()
this.MessageInput.Focus()
}
CancelMessageHistoryDialog(*)
{
this.CloseMessageHistoryDialog()
}
CloseMessageHistoryDialog()
{
DllCall(
"EnableWindow",
"Ptr", this.Window.Hwnd,
"Int", true,
"Int"
)
if (
IsObject(this.HistoryDialog)
&& WinExist(
"ahk_id "
. this.HistoryDialog.Hwnd
)
)
{
this.HistoryDialog.Destroy()
}
this.HistoryDialog := 0
this.ActivateWindow(
this.Window.Hwnd
)
}
RecordMessageHistory(messageText)
{
trimmedText :=
Trim(messageText)
if (trimmedText = "")
{
return
}
; 같은 내용이 이미 맨 위에 있으면 중복 추가하지 않는다.
if (
this.MessageHistory.Length > 0
&& this.MessageHistory[1] = trimmedText
)
{
return
}
this.MessageHistory.InsertAt(
1,
trimmedText
)
if (this.MessageHistory.Length > 30)
{
this.MessageHistory.RemoveAt(
31
)
}
this.SaveMessageHistory()
}
; 2026-07 추가: 메시지 기록을 파일에서 불러온다.
; 파일이 없거나 읽기 실패해도 빈 목록으로 시작할 뿐,
; 프로그램 실행에는 지장이 없다.
LoadMessageHistory()
{
resultArray := []
if (
Trim(this.MessageHistoryPath) = ""
|| !FileExist(this.MessageHistoryPath)
)
{
return resultArray
}
try
{
fileText :=
FileRead(
this.MessageHistoryPath,
"UTF-8"
)
}
catch
{
return resultArray
}
for lineText in StrSplit(
fileText,
"`n",
"`r"
)
{
trimmedLine :=
Trim(lineText)
if (trimmedLine = "")
{
continue
}
resultArray.Push(
trimmedLine
)
if (resultArray.Length >= 30)
{
break
}
}
return resultArray
}
; 메시지 기록을 파일에 저장한다 (한 줄에 하나, 최신이 위).
SaveMessageHistory()
{
if (Trim(this.MessageHistoryPath) = "")
{
return
}
try
{
fileContent := ""
for historyItem in this.MessageHistory
{
fileContent .=
historyItem
. "`n"
}
if (FileExist(this.MessageHistoryPath))
{
FileDelete(
this.MessageHistoryPath
)
}
FileAppend(
fileContent,
this.MessageHistoryPath,
"UTF-8"
)
}
catch
{
; 저장에 실패해도 프로그램은 계속 실행되어야 한다.
}
}
Destroy()
{
if (IsObject(this.LivePollBound))
{
SetTimer(
this.LivePollBound,
0
)
}
if (
IsObject(this.DeleteDialog)
&& WinExist(
"ahk_id "
. this.DeleteDialog.Hwnd
)
)
{
this.DeleteDialog.Destroy()
}
this.DeleteDialog := 0
if (
IsObject(this.HistoryDialog)
&& WinExist(
"ahk_id "
. this.HistoryDialog.Hwnd
)
)
{
this.HistoryDialog.Destroy()
}
this.HistoryDialog := 0
if (
IsObject(this.Window)
&& WinExist(
"ahk_id "
. this.Window.Hwnd
)
)
{
this.Window.Destroy()
}
}
}
🔵 App/Windows/AdminIdentityWindow.ahk — 관리자 ID 등록·삭제 창 #
;============================================================
; AdminIdentityWindow
;
; 역할:
; - 관리자 YouTube 채널 ID와 표시 이름 등록/삭제
; - 닫기 버튼은 프로그램 종료가 아니라 숨기기
;============================================================
class AdminIdentityWindow
{
__New(
repositoryObject,
logWindowObject
)
{
if (!IsObject(repositoryObject))
{
throw TypeError(
"AdminIdentityWindow에는 AdminIdentityRepository 객체가 필요합니다."
)
}
if (!IsObject(logWindowObject))
{
throw TypeError(
"AdminIdentityWindow에는 LogWindow 객체가 필요합니다."
)
}
this.Repository :=
repositoryObject
this.LogWindow :=
logWindowObject
this.ChangeCallback := 0
this.CreateWindow()
this.CreateControls()
this.ConnectEvents()
this.RefreshList()
}
CreateWindow()
{
this.Window :=
Gui(
"+AlwaysOnTop +ToolWindow",
"OBSLiveChat 관리자 ID"
)
this.Window.SetFont(
"s10",
"맑은 고딕"
)
}
CreateControls()
{
this.Window.AddText(
"x20 y20 w90 h24",
"YouTube 채널 ID"
)
this.ChannelIdInput :=
this.Window.AddEdit(
"x120 y16 w300 h27"
)
this.Window.AddText(
"x20 y60 w90 h24",
"표시 이름"
)
this.DisplayNameInput :=
this.Window.AddEdit(
"x120 y56 w300 h27"
)
this.AddButton :=
this.Window.AddButton(
"x440 y16 w100 h30",
"등록"
)
this.DeleteButton :=
this.Window.AddButton(
"x440 y54 w100 h30",
"선택 삭제"
)
this.IdentityList :=
this.Window.AddListView(
"x20 y105 w520 h250 Grid -Multi",
[
"YouTube 채널 ID",
"표시 이름"
]
)
this.IdentityList.ModifyCol(
1,
300
)
this.IdentityList.ModifyCol(
2,
190
)
this.StatusText :=
this.Window.AddText(
"x20 y370 w390 h45",
"채널 ID 또는 표시 이름 중 하나만 입력해도 됩니다."
)
this.HideButton :=
this.Window.AddButton(
"x430 y370 w110 h32",
"창 숨기기"
)
}
ConnectEvents()
{
this.AddButton.OnEvent(
"Click",
ObjBindMethod(
this,
"AddIdentity"
)
)
this.DeleteButton.OnEvent(
"Click",
ObjBindMethod(
this,
"DeleteSelectedIdentity"
)
)
this.HideButton.OnEvent(
"Click",
ObjBindMethod(
this,
"Hide"
)
)
this.Window.OnEvent(
"Close",
ObjBindMethod(
this,
"Hide"
)
)
this.Window.OnEvent(
"Escape",
ObjBindMethod(
this,
"Hide"
)
)
}
SetChangeCallback(callbackObject)
{
if (!IsObject(callbackObject))
{
throw TypeError(
"관리자 ID 변경 Callback이 필요합니다."
)
}
this.ChangeCallback :=
callbackObject
}
NotifyChanged()
{
if (IsObject(this.ChangeCallback))
{
this.ChangeCallback.Call()
}
}
Show(*)
{
this.RefreshList()
this.Window.Show(
"x930 y610 w565 h430"
)
this.Activate()
this.DisplayNameInput.Focus()
}
Hide(*)
{
if (
IsObject(this.Window)
&& WinExist(
"ahk_id "
. this.Window.Hwnd
)
)
{
this.Window.Hide()
}
}
AddIdentity(*)
{
channelId :=
Trim(
this.ChannelIdInput.Value
)
displayName :=
Trim(
this.DisplayNameInput.Value
)
if (
channelId = ""
&& displayName = ""
)
{
this.StatusText.Value :=
"채널 ID 또는 표시 이름 중 하나는 입력해야 합니다."
this.DisplayNameInput.Focus()
return
}
if (
!this.Repository.Add(
channelId,
displayName
)
)
{
this.StatusText.Value :=
"이미 등록된 관리자입니다."
this.DisplayNameInput.Focus()
return
}
this.RefreshList()
this.LogWindow.AddEvent(
"관리자 등록",
(
channelId != ""
? channelId
: "(채널 ID 없음)"
)
. " | "
. (
displayName != ""
? displayName
: "(표시 이름 없음)"
)
)
this.StatusText.Value :=
"관리자 등록 완료"
this.NotifyChanged()
this.ChannelIdInput.Value := ""
this.DisplayNameInput.Value := ""
this.DisplayNameInput.Focus()
}
DeleteSelectedIdentity(*)
{
selectedRow :=
this.IdentityList.GetNext(
0
)
if (selectedRow = 0)
{
this.StatusText.Value :=
"삭제할 관리자를 먼저 선택하세요."
return
}
channelId :=
this.IdentityList.GetText(
selectedRow,
1
)
displayName :=
this.IdentityList.GetText(
selectedRow,
2
)
if (
!this.Repository.Delete(
channelId,
displayName
)
)
{
this.StatusText.Value :=
"관리자 삭제에 실패했습니다."
return
}
this.RefreshList()
this.LogWindow.AddEvent(
"관리자 삭제",
(
channelId != ""
? channelId
: "(채널 ID 없음)"
)
. " | "
. (
displayName != ""
? displayName
: "(표시 이름 없음)"
)
)
this.StatusText.Value :=
"관리자 삭제 완료"
this.NotifyChanged()
}
RefreshList(*)
{
this.IdentityList.Delete()
identityObjects :=
this.Repository.LoadAll()
for identityObject in identityObjects
{
this.IdentityList.Add(
"",
identityObject[
"ChannelId"
],
identityObject[
"DisplayName"
]
)
}
this.StatusText.Value :=
"등록된 관리자 "
. identityObjects.Length
. "명"
}
Activate()
{
DllCall(
"SetWindowPos",
"Ptr", this.Window.Hwnd,
"Ptr", -1,
"Int", 0,
"Int", 0,
"Int", 0,
"Int", 0,
"UInt", 0x43,
"Int"
)
DllCall(
"SetForegroundWindow",
"Ptr", this.Window.Hwnd,
"Int"
)
}
Destroy()
{
if (
IsObject(this.Window)
&& WinExist(
"ahk_id "
. this.Window.Hwnd
)
)
{
this.Window.Destroy()
}
}
}
🔵 App/Windows/OverlayWindow.ahk — OBS로 캡처하는 투명 송출 창 #
;============================================================
; OverlayWindow
;
; 역할:
; - 투명 송출창 생성
; - 렌더러와 Viewport 연결
; - 표시 개수 변경
; - 창 위쪽 고정, 아래쪽 크기만 변경
;============================================================
class OverlayWindow
{
__New(
styleConfiguration,
styleResolverObject,
textLayoutObject,
visibleWindowSizerObject,
overlaySettings,
fontsFolderPath := ""
)
{
if (Type(styleConfiguration) != "Map")
{
throw TypeError(
"OverlayWindow에는 스타일 설정 Map이 필요합니다."
)
}
if (Type(overlaySettings) != "Map")
{
throw TypeError(
"OverlayWindow에는 오버레이 설정 Map이 필요합니다."
)
}
this.FontsFolderPath :=
fontsFolderPath
this.StyleConfiguration :=
styleConfiguration
this.StyleResolver :=
styleResolverObject
this.TextLayout :=
textLayoutObject
this.VisibleWindowSizer :=
visibleWindowSizerObject
this.Settings :=
overlaySettings
this.CurrentVisibleLimit :=
overlaySettings[
"VisibleMessageLimit"
]
this.CurrentRenderedCount := 0
this.Width :=
overlaySettings[
"Width"
]
this.Height :=
this.VisibleWindowSizer.CalculateHeight(
this.CurrentVisibleLimit,
this.StyleConfiguration[
"Styles"
]
)
this.CreateWindow()
this.CreateRenderingPipeline()
}
CreateWindow()
{
; 2026-07 재수정:
; ToolWindow 대신 "소유자 창(Owner)" 기법을 쓴다.
; 소유자가 있는 창은 원래 작업표시줄에 자기 아이콘을
; 안 만드는데, 이건 ToolWindow 스타일이 아니라서
; OBS의 윈도우 캡처 목록에는 정상적으로 계속 뜬다.
; (ToolWindow만 못 찾는 문제였지, Owner가 있다고
; 못 찾는 건 아니다.)
;
; 이 숨김 창은 절대 Show()하지 않는다 — 그래야
; 이 창 자체는 화면에도, 작업표시줄에도 안 나타난다.
this.HiddenOwnerWindow :=
Gui(
"",
""
)
windowOptions :=
"-Caption +E0x80000 +Owner"
. this.HiddenOwnerWindow.Hwnd
if (
this.Settings[
"AlwaysOnTop"
]
)
{
windowOptions .=
" +AlwaysOnTop"
}
if (
this.Settings[
"ClickThrough"
]
)
{
windowOptions .=
" +E0x20"
}
this.Window :=
Gui(
windowOptions,
"OBSLiveChat 송출 오버레이"
)
this.Window.Show(
"x"
. this.Settings["X"]
. " y"
. this.Settings["Y"]
. " w"
. this.Width
. " h"
. this.Height
. " NA"
)
}
CreateRenderingPipeline()
{
this.Viewport :=
ViewportLayout(
this.TextLayout,
this.Height,
this.Settings[
"TopMargin"
],
this.Settings[
"BottomMargin"
],
"Top"
)
this.Renderer :=
LayeredOverlayRenderer(
this.Window.Hwnd,
this.FontsFolderPath
)
this.Controller :=
LiveOverlayController(
this.StyleResolver,
this.TextLayout,
this.Viewport,
this.Renderer,
this.Settings[
"BufferCapacity"
],
this.CurrentVisibleLimit
)
; 2026-07 추가: 채팅 없이 일정 시간 지나면
; 서서히 사라지는 기능용 상태값.
this.LastActivityTickCount := A_TickCount
this.CurrentOpacityByte := 255
this.IdleTimeoutMs := 300000
this.FadeDurationMs := 10000
}
AddMessage(
authorText,
messageText,
messageType
)
{
this.CurrentRenderedCount :=
this.Controller.AddMessage(
authorText,
messageText,
messageType
)
this.RestoreFullOpacity()
return this.CurrentRenderedCount
}
; 2026-07 추가:
; 새 채팅이 들어올 때마다 유휴 시각을 지금으로 갱신하고,
; 페이드 중이었다면 즉시 완전 불투명으로 되돌린다.
RestoreFullOpacity()
{
this.LastActivityTickCount :=
A_TickCount
if (this.CurrentOpacityByte != 255)
{
this.CurrentOpacityByte := 255
this.Renderer.SetWindowOpacityByte(
255
)
}
}
; 2026-07 추가:
; 외부(Application)에서 1초 간격 정도로 호출해주는 함수.
; 유휴 시간이 IdleTimeoutMs를 넘으면 FadeDurationMs에 걸쳐
; 서서히 투명해진다. 실제로 값이 바뀔 때만 다시 그린다.
Tick()
{
elapsedMs :=
A_TickCount
- this.LastActivityTickCount
if (elapsedMs < this.IdleTimeoutMs)
{
return
}
overIdleMs :=
elapsedMs
- this.IdleTimeoutMs
fadeProgress :=
Min(
1.0,
overIdleMs / this.FadeDurationMs
)
targetOpacityByte :=
Round(
255 * (1.0 - fadeProgress)
)
if (targetOpacityByte = this.CurrentOpacityByte)
{
return
}
this.CurrentOpacityByte :=
targetOpacityByte
this.Renderer.SetWindowOpacityByte(
targetOpacityByte
)
}
ClearMessages()
{
this.Controller.ClearMessages()
this.CurrentRenderedCount := 0
}
SetVisibleMessageLimit(
visibleMessageLimit
)
{
this.ValidateVisibleMessageLimit(
visibleMessageLimit
)
this.CurrentVisibleLimit :=
visibleMessageLimit
this.Height :=
this.VisibleWindowSizer.CalculateHeight(
visibleMessageLimit,
this.StyleConfiguration[
"Styles"
]
)
currentX := 0
currentY := 0
currentWidth := 0
currentHeight := 0
WinGetPos(
¤tX,
¤tY,
¤tWidth,
¤tHeight,
"ahk_id "
. this.Window.Hwnd
)
; Layered 창은 WinMove로 먼저 변경하지 않는다.
; UpdateLayeredWindow가 위치, 크기, 투명 픽셀,
; 텍스트를 한 번에 적용한다.
this.Renderer.SetWindowBounds(
currentX,
currentY,
this.Width,
this.Height
)
this.Viewport.ViewportHeight :=
this.Height
this.CurrentRenderedCount :=
this.Controller.SetVisibleMessageLimit(
visibleMessageLimit
)
return this.CurrentRenderedCount
}
GetVisibleMessageLimit()
{
return this.CurrentVisibleLimit
}
GetRenderedMessageCount()
{
return this.CurrentRenderedCount
}
GetBufferCapacity()
{
return this.Settings[
"BufferCapacity"
]
}
GetWindowHandle()
{
return this.Window.Hwnd
}
ValidateVisibleMessageLimit(
visibleMessageLimit
)
{
if (
!IsNumber(visibleMessageLimit)
|| visibleMessageLimit < 1
|| Floor(visibleMessageLimit)
!= visibleMessageLimit
)
{
throw ValueError(
"표시 개수는 1 이상의 정수여야 합니다.",
-1,
visibleMessageLimit
)
}
if (
visibleMessageLimit
> this.GetBufferCapacity()
)
{
throw ValueError(
"표시 개수는 버퍼 크기보다 클 수 없습니다.",
-1,
visibleMessageLimit
)
}
}
Shutdown()
{
; IsSet()은 일반 변수에만 사용할 수 있고
; this.Renderer 같은 객체 속성에는 사용할 수 없다.
if (
this.HasOwnProp(
"Renderer"
)
&& IsObject(
this.Renderer
)
)
{
this.Renderer.Shutdown()
}
if (
this.HasOwnProp(
"Window"
)
&& IsObject(
this.Window
)
&& WinExist(
"ahk_id "
. this.Window.Hwnd
)
)
{
this.Window.Destroy()
}
}
}
🔵 App/Windows/LogWindow.ahk — 작업·송출 기록 창 #
;============================================================
; LogWindow
;
; 역할:
; - 관리자 작업과 송출 기록 표시
; - 창 닫기 버튼은 프로그램 종료가 아니라 숨기기
; - 로그 비우기와 새로고침
;============================================================
class LogWindow
{
__New(eventLogServiceObject)
{
if (!IsObject(eventLogServiceObject))
{
throw TypeError(
"LogWindow에는 EventLogService 객체가 필요합니다."
)
}
this.LogService :=
eventLogServiceObject
this.CreateWindow()
this.CreateControls()
this.ConnectEvents()
this.Refresh()
}
CreateWindow()
{
this.Window :=
Gui(
"+AlwaysOnTop +ToolWindow",
"OBSLiveChat 로그"
)
this.Window.SetFont(
"s10",
"맑은 고딕"
)
}
CreateControls()
{
this.EventList :=
this.Window.AddListView(
"x20 y20 w860 h420 Grid -Multi",
[
"시간",
"구분",
"내용"
]
)
this.EventList.ModifyCol(
1,
155
)
this.EventList.ModifyCol(
2,
115
)
this.EventList.ModifyCol(
3,
560
)
this.RefreshButton :=
this.Window.AddButton(
"x20 y455 w100 h32",
"새로고침"
)
this.ClearButton :=
this.Window.AddButton(
"x130 y455 w100 h32",
"로그 비우기"
)
this.HideButton :=
this.Window.AddButton(
"x240 y455 w100 h32",
"창 숨기기"
)
this.StatusText :=
this.Window.AddText(
"x365 y461 w515 h28",
"로그 준비 완료"
)
}
ConnectEvents()
{
this.RefreshButton.OnEvent(
"Click",
ObjBindMethod(
this,
"Refresh"
)
)
this.ClearButton.OnEvent(
"Click",
ObjBindMethod(
this,
"ClearLog"
)
)
this.HideButton.OnEvent(
"Click",
ObjBindMethod(
this,
"Hide"
)
)
this.Window.OnEvent(
"Close",
ObjBindMethod(
this,
"Hide"
)
)
this.Window.OnEvent(
"Escape",
ObjBindMethod(
this,
"Hide"
)
)
}
Show(*)
{
this.Refresh()
this.Window.Show(
"x930 y610 w900 h510"
)
this.Activate()
}
Hide(*)
{
if (
IsObject(this.Window)
&& WinExist(
"ahk_id "
. this.Window.Hwnd
)
)
{
this.Window.Hide()
}
}
AddEvent(
eventType,
details
)
{
eventObject :=
this.LogService.Append(
eventType,
details
)
this.AddEventRow(
eventObject
)
return eventObject
}
Refresh(*)
{
this.EventList.Delete()
eventObjects :=
this.LogService.LoadRecent(
300
)
for eventObject in eventObjects
{
this.AddEventRow(
eventObject
)
}
this.StatusText.Value :=
"최근 로그 "
. eventObjects.Length
. "개 / "
. this.LogService.GetLogFilePath()
}
ClearLog(*)
{
this.LogService.Clear()
this.EventList.Delete()
this.StatusText.Value :=
"로그를 모두 비웠습니다."
}
AddEventRow(eventObject)
{
this.EventList.Add(
"",
eventObject[
"Timestamp"
],
eventObject[
"EventType"
],
eventObject[
"Details"
]
)
rowCount :=
this.EventList.GetCount()
if (rowCount > 0)
{
this.EventList.Modify(
rowCount,
"Vis"
)
}
}
Activate()
{
DllCall(
"SetWindowPos",
"Ptr", this.Window.Hwnd,
"Ptr", -1,
"Int", 0,
"Int", 0,
"Int", 0,
"Int", 0,
"UInt", 0x43,
"Int"
)
DllCall(
"SetForegroundWindow",
"Ptr", this.Window.Hwnd,
"Int"
)
}
Destroy()
{
if (
IsObject(this.Window)
&& WinExist(
"ahk_id "
. this.Window.Hwnd
)
)
{
this.Window.Destroy()
}
}
}
🟢 Engine · Model — 데이터 객체 #
🟢 Engine/Model/ChatMessage.ahk — 채팅 메시지 1건 #
;============================================================
; ChatMessage
;
; 채팅 메시지 한 건을 표현하는 데이터 객체.
;
; 사용자 이름, 메시지 내용, 메시지 종류,
; 생성 시간을 보관한다.
;============================================================
class ChatMessage
{
;--------------------------------------------------------
; __New()
; 새로운 채팅 메시지 객체 생성
;--------------------------------------------------------
__New(userName, messageText, messageType := "Normal")
{
this.UserName := userName
this.Text := messageText
this.Type := messageType
this.CreatedAt := FormatTime(
,
"yyyy-MM-dd HH:mm:ss"
)
}
;--------------------------------------------------------
; ToDisplayText()
; 화면 출력용 기본 문자열 반환
;--------------------------------------------------------
ToDisplayText()
{
return "[" this.UserName "] " this.Text
}
}
🟢 Engine/Model/MessageBuffer.ahk — 최근 메시지 N개만 유지하는 버퍼 #
;============================================================
; MessageBuffer
;
; 최근 채팅 메시지를 일정 개수만 유지하는 저장소.
;
; 최대 개수를 초과하면 가장 오래된 메시지부터
; 자동으로 제거하는 FIFO 구조를 사용한다.
;============================================================
class MessageBuffer
{
;--------------------------------------------------------
; __New()
; 메시지 최대 보관 개수 설정
;--------------------------------------------------------
__New(maximumMessageCount := 20)
{
if (maximumMessageCount < 1)
{
throw ValueError(
"메시지 최대 개수는 1 이상이어야 합니다.",
-1,
maximumMessageCount
)
}
this.MaximumMessageCount := maximumMessageCount
this.Items := []
}
;--------------------------------------------------------
; Add()
; 새로운 메시지를 버퍼 마지막에 추가
;--------------------------------------------------------
Add(messageObject)
{
if (!IsObject(messageObject))
{
throw TypeError(
"MessageBuffer에는 객체만 추가할 수 있습니다."
)
}
this.Items.Push(messageObject)
this.RemoveOverflowMessages()
}
;--------------------------------------------------------
; RemoveOverflowMessages()
; 최대 개수를 초과한 오래된 메시지 제거
;--------------------------------------------------------
RemoveOverflowMessages()
{
while (this.Items.Length > this.MaximumMessageCount)
{
this.Items.RemoveAt(1)
}
}
;--------------------------------------------------------
; GetMessages()
; 현재 메시지 목록의 복사본 반환
;
; 외부 코드가 내부 배열을 직접 수정하지 못하도록
; Clone()을 사용한다.
;--------------------------------------------------------
GetMessages()
{
return this.Items.Clone()
}
;--------------------------------------------------------
; GetCount()
; 현재 저장된 메시지 개수 반환
;--------------------------------------------------------
GetCount()
{
return this.Items.Length
}
;--------------------------------------------------------
; GetOldest()
; 가장 오래된 메시지 반환
;--------------------------------------------------------
GetOldest()
{
if (this.Items.Length = 0)
return ""
return this.Items[1]
}
;--------------------------------------------------------
; GetNewest()
; 가장 최근 메시지 반환
;--------------------------------------------------------
GetNewest()
{
if (this.Items.Length = 0)
return ""
return this.Items[this.Items.Length]
}
;--------------------------------------------------------
; Clear()
; 저장된 모든 메시지 삭제
;--------------------------------------------------------
Clear()
{
this.Items := []
}
}
🟢 Engine/Model/ChatMacro.ahk — 채팅 매크로 1개 #
;============================================================
; ChatMacro
;
; Build 2.4
;
; 관리자 채팅 매크로 한 개를 표현한다.
;============================================================
class ChatMacro
{
__New(
macroId,
category,
macroName,
author,
messageType,
messageText
)
{
this.ValidateNonEmptyString(
macroId,
"매크로 ID"
)
this.ValidateNonEmptyString(
category,
"카테고리"
)
this.ValidateNonEmptyString(
macroName,
"매크로 이름"
)
this.ValidateNonEmptyString(
author,
"작성자"
)
this.ValidateNonEmptyString(
messageType,
"메시지 종류"
)
this.ValidateNonEmptyString(
messageText,
"메시지"
)
this.Id := Trim(macroId)
this.Category := Trim(category)
this.Name := Trim(macroName)
this.Author := Trim(author)
this.MessageType := Trim(messageType)
this.Text := Trim(messageText)
}
ValidateNonEmptyString(
value,
valueName
)
{
if (Type(value) != "String")
{
throw TypeError(
valueName
. "은 문자열이어야 합니다."
)
}
if (Trim(value) = "")
{
throw ValueError(
valueName
. "은 비어 있을 수 없습니다."
)
}
}
}
🟠 Engine · Configuration — 설정 읽기 #
🟠 Engine/Configuration/StyleLoader.ahk — Styles.ini를 읽어 종류별 TextStyle 표 만들기 #
;============================================================
; StyleLoader
;
; UTF-8 Styles.ini를 직접 읽어 TextStyle 테이블을 만든다.
;
; Build 1.9.1
; - [Defaults] FallbackType
; - [Style.*] 섹션
; - GlowEnabled / GlowColor / GlowSize / GlowOpacity
; - 메시지 사각 배경 설정 없음
;============================================================
#Include ..\Rendering\TextStyle.ahk
class StyleLoader
{
Load(styleFilePath)
{
if (Type(styleFilePath) != "String" || Trim(styleFilePath) = "")
throw TypeError("Styles.ini 경로가 필요합니다.")
if (!FileExist(styleFilePath))
throw TargetError("Styles.ini 파일을 찾을 수 없습니다.", -1, styleFilePath)
iniText := FileRead(styleFilePath, "UTF-8")
iniSections := this.ParseIniText(iniText)
if (!iniSections.Has("defaults"))
throw ValueError("Styles.ini에 [Defaults] 섹션이 없습니다.")
defaultsSection := iniSections["defaults"]
fallbackType := StrLower(
this.ParseRequiredText(
this.GetRequiredValue(
defaultsSection,
"fallbacktype",
"[Defaults] FallbackType"
),
"[Defaults] FallbackType"
)
)
styleTable := this.BuildStyleTable(
iniSections,
defaultsSection
)
if (!styleTable.Has(fallbackType))
{
throw ValueError(
"[Defaults] FallbackType에 지정된 스타일이 없습니다: "
. fallbackType
)
}
return Map(
"FallbackType", fallbackType,
"Styles", styleTable
)
}
ParseIniText(iniText)
{
sections := Map()
currentSectionName := ""
iniText := StrReplace(iniText, "`r`n", "`n")
iniText := StrReplace(iniText, "`r", "`n")
Loop Parse iniText, "`n"
{
lineText := Trim(A_LoopField)
if (lineText = "")
continue
firstCharacter := SubStr(lineText, 1, 1)
if (firstCharacter = ";" || firstCharacter = "#")
continue
if (RegExMatch(lineText, "^\[(.+)\]$", §ionMatch))
{
currentSectionName := StrLower(
Trim(sectionMatch[1])
)
if (currentSectionName = "")
throw ValueError("빈 INI 섹션 이름은 사용할 수 없습니다.")
if (sections.Has(currentSectionName))
{
throw ValueError(
"INI 섹션이 중복되었습니다: ["
. currentSectionName
. "]"
)
}
sections[currentSectionName] := Map()
continue
}
if (currentSectionName = "")
{
throw ValueError(
"INI 항목보다 섹션 선언이 먼저 나와야 합니다: "
. lineText
)
}
separatorPosition := InStr(lineText, "=")
if (separatorPosition <= 1)
throw ValueError("잘못된 INI 항목입니다: " lineText)
keyName := StrLower(
Trim(
SubStr(
lineText,
1,
separatorPosition - 1
)
)
)
keyValue := Trim(
SubStr(
lineText,
separatorPosition + 1
)
)
if (keyName = "")
throw ValueError("빈 INI 항목 이름은 사용할 수 없습니다.")
if (sections[currentSectionName].Has(keyName))
{
throw ValueError(
"INI 항목이 중복되었습니다: ["
. currentSectionName
. "] "
. keyName
)
}
sections[currentSectionName][keyName] := keyValue
}
return sections
}
BuildStyleTable(iniSections, defaultsSection)
{
styleTable := Map()
for sectionName, sectionValues in iniSections
{
if (!RegExMatch(sectionName, "^style\.(.+)$", &styleMatch))
continue
styleType := StrLower(
Trim(styleMatch[1])
)
if (styleType = "")
throw ValueError("빈 [Style.*] 이름은 사용할 수 없습니다.")
if (styleTable.Has(styleType))
throw ValueError("스타일이 중복되었습니다: " styleType)
styleTable[styleType] := this.BuildTextStyle(
sectionName,
sectionValues,
defaultsSection
)
}
if (styleTable.Count = 0)
throw ValueError("Styles.ini에 [Style.*] 섹션이 없습니다.")
return styleTable
}
BuildTextStyle(sectionName, sectionValues, defaultsSection)
{
displaySectionName := "[" sectionName "]"
fontName := this.ParseRequiredText(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"fontname",
displaySectionName " FontName"
),
displaySectionName " FontName"
)
fontSize := this.ParseIntegerRange(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"fontsize",
displaySectionName " FontSize"
),
1,
300,
displaySectionName " FontSize"
)
fontWeight := this.ParseIntegerRange(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"fontweight",
displaySectionName " FontWeight"
),
1,
1000,
displaySectionName " FontWeight"
)
color := this.ParseColor(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"color",
displaySectionName " Color"
),
displaySectionName " Color"
)
opacity := this.ParseIntegerRange(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"opacity",
displaySectionName " Opacity"
),
0,
255,
displaySectionName " Opacity"
)
shadowEnabled := this.ParseBoolean(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"shadowenabled",
displaySectionName " ShadowEnabled"
),
displaySectionName " ShadowEnabled"
)
shadowColor := this.ParseColor(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"shadowcolor",
displaySectionName " ShadowColor"
),
displaySectionName " ShadowColor"
)
shadowOffsetX := this.ParseInteger(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"shadowoffsetx",
displaySectionName " ShadowOffsetX"
),
displaySectionName " ShadowOffsetX"
)
shadowOffsetY := this.ParseInteger(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"shadowoffsety",
displaySectionName " ShadowOffsetY"
),
displaySectionName " ShadowOffsetY"
)
outlineEnabled := this.ParseBoolean(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"outlineenabled",
displaySectionName " OutlineEnabled"
),
displaySectionName " OutlineEnabled"
)
outlineColor := this.ParseColor(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"outlinecolor",
displaySectionName " OutlineColor"
),
displaySectionName " OutlineColor"
)
outlineSize := this.ParseIntegerRange(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"outlinesize",
displaySectionName " OutlineSize"
),
0,
20,
displaySectionName " OutlineSize"
)
glowEnabled := this.ParseBoolean(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"glowenabled",
displaySectionName " GlowEnabled"
),
displaySectionName " GlowEnabled"
)
glowColor := this.ParseColor(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"glowcolor",
displaySectionName " GlowColor"
),
displaySectionName " GlowColor"
)
glowSize := this.ParseIntegerRange(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"glowsize",
displaySectionName " GlowSize"
),
0,
12,
displaySectionName " GlowSize"
)
glowOpacity := this.ParseIntegerRange(
this.GetInheritedValue(
sectionValues,
defaultsSection,
"glowopacity",
displaySectionName " GlowOpacity"
),
0,
255,
displaySectionName " GlowOpacity"
)
return TextStyle(
fontName,
fontSize,
fontWeight,
color,
opacity,
shadowEnabled,
shadowColor,
shadowOffsetX,
shadowOffsetY,
outlineEnabled,
outlineColor,
outlineSize,
glowEnabled,
glowColor,
glowSize,
glowOpacity
)
}
GetInheritedValue(sectionValues, defaultsSection, keyName, displayName)
{
normalizedKeyName := StrLower(keyName)
if (sectionValues.Has(normalizedKeyName))
return sectionValues[normalizedKeyName]
if (defaultsSection.Has(normalizedKeyName))
return defaultsSection[normalizedKeyName]
throw ValueError(displayName " 항목이 없습니다.")
}
GetRequiredValue(sectionValues, keyName, displayName)
{
normalizedKeyName := StrLower(keyName)
if (!sectionValues.Has(normalizedKeyName))
throw ValueError(displayName " 항목이 없습니다.")
return sectionValues[normalizedKeyName]
}
ParseRequiredText(valueText, displayName)
{
if (Type(valueText) != "String")
throw TypeError(displayName "은 문자열이어야 합니다.")
parsedText := Trim(valueText)
if (parsedText = "")
throw ValueError(displayName "은 비어 있을 수 없습니다.")
return parsedText
}
ParseBoolean(valueText, displayName)
{
normalizedValue := StrLower(
Trim(valueText)
)
if (
normalizedValue = "true"
|| normalizedValue = "yes"
|| normalizedValue = "on"
|| normalizedValue = "1"
)
{
return true
}
if (
normalizedValue = "false"
|| normalizedValue = "no"
|| normalizedValue = "off"
|| normalizedValue = "0"
)
{
return false
}
throw ValueError(
displayName "은 True 또는 False여야 합니다.",
-1,
valueText
)
}
ParseInteger(valueText, displayName)
{
normalizedValue := Trim(valueText)
if (!RegExMatch(normalizedValue, "^[+-]?\d+$"))
throw ValueError(displayName "은 정수여야 합니다.", -1, valueText)
return normalizedValue + 0
}
ParseIntegerRange(valueText, minimumValue, maximumValue, displayName)
{
parsedValue := this.ParseInteger(
valueText,
displayName
)
if (
parsedValue < minimumValue
|| parsedValue > maximumValue
)
{
throw ValueError(
displayName
. "은 "
. minimumValue
. "부터 "
. maximumValue
. " 사이여야 합니다.",
-1,
parsedValue
)
}
return parsedValue
}
ParseColor(valueText, displayName)
{
normalizedColor := StrUpper(
Trim(valueText)
)
if (SubStr(normalizedColor, 1, 1) = "#")
normalizedColor := SubStr(normalizedColor, 2)
if (!RegExMatch(normalizedColor, "^[0-9A-F]{6}$"))
{
throw ValueError(
displayName "은 RRGGBB 형식이어야 합니다.",
-1,
valueText
)
}
return normalizedColor
}
}
🟠 Engine/Configuration/OverlaySettingsLoader.ahk — Overlay.ini 읽기 (크기·위치·여백·표시 개수) #
;============================================================
; OverlaySettingsLoader
;
; Build 2.6
;
; OBS 송출 오버레이의 크기, 위치, 여백,
; 자동 줄바꿈 폭, 최근 메시지 보관 수를 읽는다.
;============================================================
class OverlaySettingsLoader
{
Load(settingsPath)
{
if (Type(settingsPath) != "String")
{
throw TypeError(
"오버레이 설정 경로는 문자열이어야 합니다."
)
}
if (!FileExist(settingsPath))
{
throw Error(
"오버레이 설정 파일을 찾을 수 없습니다.`n"
. settingsPath
)
}
settings :=
Map(
"X",
this.ReadInteger(
settingsPath,
"Overlay",
"X",
20,
0
),
"Y",
this.ReadInteger(
settingsPath,
"Overlay",
"Y",
20,
0
),
"Width",
this.ReadInteger(
settingsPath,
"Overlay",
"Width",
1000,
100
),
"Height",
this.ReadInteger(
settingsPath,
"Overlay",
"Height",
1040,
100
),
"LeftMargin",
this.ReadInteger(
settingsPath,
"Layout",
"LeftMargin",
35,
0
),
"TopMargin",
this.ReadInteger(
settingsPath,
"Layout",
"TopMargin",
25,
0
),
"BottomMargin",
this.ReadInteger(
settingsPath,
"Layout",
"BottomMargin",
25,
0
),
"MaximumTextWidth",
this.ReadInteger(
settingsPath,
"Layout",
"MaximumTextWidth",
930,
1
),
"MinimumMessageHeight",
this.ReadInteger(
settingsPath,
"Layout",
"MinimumMessageHeight",
38,
1
),
"MessageGap",
this.ReadInteger(
settingsPath,
"Layout",
"MessageGap",
7,
0
),
"TextLineGap",
this.ReadInteger(
settingsPath,
"Layout",
"TextLineGap",
4,
0
),
"BufferCapacity",
this.ReadInteger(
settingsPath,
"Messages",
"BufferCapacity",
20,
1
),
"VisibleMessageLimit",
this.ReadInteger(
settingsPath,
"Messages",
"VisibleMessageLimit",
20,
1
),
"AlwaysOnTop",
this.ReadBoolean(
settingsPath,
"Window",
"AlwaysOnTop",
true
),
"ClickThrough",
this.ReadBoolean(
settingsPath,
"Window",
"ClickThrough",
true
)
)
this.ValidateSettings(
settings
)
return settings
}
ReadInteger(
settingsPath,
sectionName,
keyName,
defaultValue,
minimumValue
)
{
rawValue :=
Trim(
IniRead(
settingsPath,
sectionName,
keyName,
defaultValue
)
)
if (
!RegExMatch(
rawValue,
"^-?\d+$"
)
)
{
throw ValueError(
"["
. sectionName
. "] "
. keyName
. " 값은 정수여야 합니다.",
-1,
rawValue
)
}
integerValue :=
rawValue + 0
if (integerValue < minimumValue)
{
throw ValueError(
"["
. sectionName
. "] "
. keyName
. " 값은 "
. minimumValue
. " 이상이어야 합니다.",
-1,
integerValue
)
}
return integerValue
}
ReadBoolean(
settingsPath,
sectionName,
keyName,
defaultValue
)
{
defaultText :=
defaultValue
? "True"
: "False"
rawValue :=
StrLower(
Trim(
IniRead(
settingsPath,
sectionName,
keyName,
defaultText
)
)
)
switch rawValue
{
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
}
throw ValueError(
"["
. sectionName
. "] "
. keyName
. " 값은 True 또는 False여야 합니다.",
-1,
rawValue
)
}
ValidateSettings(settings)
{
horizontalSpace :=
settings["Width"]
- settings["LeftMargin"]
if (
settings["MaximumTextWidth"]
> horizontalSpace
)
{
throw ValueError(
"MaximumTextWidth가 오버레이 가로 영역을 초과합니다."
)
}
verticalMargins :=
settings["TopMargin"]
+ settings["BottomMargin"]
if (
verticalMargins
>= settings["Height"]
)
{
throw ValueError(
"위쪽/아래쪽 여백의 합은 오버레이 높이보다 작아야 합니다."
)
}
if (
settings["VisibleMessageLimit"]
> settings["BufferCapacity"]
)
{
throw ValueError(
"VisibleMessageLimit는 BufferCapacity보다 클 수 없습니다."
)
}
}
}
🟡 Engine · Pipeline — 배치 계산 #
🟡 Engine/Pipeline/StyleResolver.ahk — 메시지 종류에 맞는 스타일 찾기 #
;============================================================
; StyleResolver
;
; ChatMessage의 메시지 종류에 해당하는 TextStyle 객체를
; StyleTable에서 찾아 그대로 반환한다.
;
; Build 1.7.3
; - StyleLoader가 생성한 TextStyle 객체를 직접 반환
; - 배경 관련 속성 완전 제거
;============================================================
class StyleResolver
{
;--------------------------------------------------------
; __New()
;--------------------------------------------------------
__New(styleConfiguration)
{
if (Type(styleConfiguration) != "Map")
{
throw TypeError(
"StyleResolver에는 스타일 설정 Map이 필요합니다."
)
}
if (!styleConfiguration.Has("FallbackType"))
{
throw ValueError(
"스타일 설정에 FallbackType이 없습니다."
)
}
if (!styleConfiguration.Has("Styles"))
{
throw ValueError(
"스타일 설정에 Styles가 없습니다."
)
}
styleTable :=
styleConfiguration["Styles"]
if (Type(styleTable) != "Map")
{
throw TypeError(
"스타일 설정의 Styles는 Map이어야 합니다."
)
}
fallbackType :=
StrLower(
Trim(
styleConfiguration["FallbackType"]
)
)
if (fallbackType = "")
{
throw ValueError(
"FallbackType은 비어 있을 수 없습니다."
)
}
if (!styleTable.Has(fallbackType))
{
throw ValueError(
"Fallback 스타일이 없습니다: "
. fallbackType
)
}
fallbackStyle :=
styleTable[fallbackType]
this.ValidateTextStyle(
fallbackStyle,
fallbackType
)
this.FallbackType :=
fallbackType
this.StyleTable :=
styleTable
}
;--------------------------------------------------------
; Resolve()
;
; ChatMessage에서 메시지 종류를 읽고,
; 해당 TextStyle 객체를 직접 반환한다.
;--------------------------------------------------------
Resolve(chatMessageObject)
{
if (!IsObject(chatMessageObject))
{
throw TypeError(
"StyleResolver에는 ChatMessage 객체가 필요합니다."
)
}
messageType :=
this.GetMessageType(
chatMessageObject
)
return this.ResolveType(
messageType
)
}
;--------------------------------------------------------
; ResolveType()
;
; 문자열 메시지 종류를 TextStyle로 변환한다.
; 알 수 없는 종류는 Fallback 스타일을 사용한다.
;--------------------------------------------------------
ResolveType(messageType)
{
normalizedMessageType := ""
if (Type(messageType) = "String")
{
normalizedMessageType :=
StrLower(
Trim(messageType)
)
}
if (
normalizedMessageType = ""
|| !this.StyleTable.Has(
normalizedMessageType
)
)
{
normalizedMessageType :=
this.FallbackType
}
textStyleObject :=
this.StyleTable[
normalizedMessageType
]
this.ValidateTextStyle(
textStyleObject,
normalizedMessageType
)
return textStyleObject
}
;--------------------------------------------------------
; GetMessageType()
;
; 현재 ChatMessage 구현과 이전 테스트 코드 모두를
; 지원하도록 흔히 사용된 속성 이름을 순서대로 확인한다.
;--------------------------------------------------------
GetMessageType(chatMessageObject)
{
if (HasProp(chatMessageObject, "Type"))
{
return chatMessageObject.Type
}
if (HasProp(chatMessageObject, "MessageType"))
{
return chatMessageObject.MessageType
}
if (HasProp(chatMessageObject, "Category"))
{
return chatMessageObject.Category
}
return this.FallbackType
}
;--------------------------------------------------------
; ValidateTextStyle()
;--------------------------------------------------------
ValidateTextStyle(
textStyleObject,
styleType
)
{
if (
!IsObject(textStyleObject)
|| Type(textStyleObject) != "TextStyle"
)
{
throw TypeError(
"StyleTable의 "
. styleType
. " 항목은 TextStyle 객체여야 합니다."
)
}
}
}
🟡 Engine/Pipeline/TextLayout.ahk — 실제 글자 폭을 재서 자동 줄바꿈·배치 #
;============================================================
; TextLayout
;
; 실제 GDI 측정값을 사용하여 메시지를 배치한다.
;
; Build 2.0
; - 최대 텍스트 폭
; - 단어 단위 자동 줄바꿈
; - 긴 URL/공백 없는 문자열의 글자 단위 분할
; - 여러 줄 높이를 다음 메시지 위치에 반영
;
; 기존 호출 호환:
; TextLayout(left, top, minimumHeight, lineGap, measurer)
;============================================================
#Include ..\Rendering\GdiTextMeasurer.ahk
#Include ..\Rendering\TextDrawCommand.ahk
class TextLayout
{
__New(
leftMargin := 30,
topMargin := 30,
minimumLineHeight := 40,
lineGap := 12,
textMeasurerObject := 0,
maximumTextWidth := 0,
textLineGap := 4
)
{
this.ValidateNonNegativeNumber(
leftMargin,
"왼쪽 여백"
)
this.ValidateNonNegativeNumber(
topMargin,
"위쪽 여백"
)
this.ValidatePositiveNumber(
minimumLineHeight,
"최소 줄 높이"
)
this.ValidateNonNegativeNumber(
lineGap,
"메시지 사이 간격"
)
this.ValidateNonNegativeNumber(
maximumTextWidth,
"최대 텍스트 폭"
)
this.ValidateNonNegativeNumber(
textLineGap,
"텍스트 줄 내부 간격"
)
if (!IsObject(textMeasurerObject))
{
if (textMeasurerObject != 0)
{
throw TypeError(
"TextLayout에는 GdiTextMeasurer 객체가 필요합니다."
)
}
textMeasurerObject :=
GdiTextMeasurer()
}
if (
Type(textMeasurerObject)
!= "GdiTextMeasurer"
)
{
throw TypeError(
"TextLayout에는 GdiTextMeasurer 객체가 필요합니다."
)
}
this.LeftMargin := leftMargin
this.TopMargin := topMargin
this.MinimumLineHeight := minimumLineHeight
this.LineGap := lineGap
this.TextMeasurer := textMeasurerObject
this.MaximumTextWidth := maximumTextWidth
this.TextLineGap := textLineGap
}
BuildCommandAtVisualTop(
chatMessageObject,
textStyleObject,
visualTop
)
{
this.ValidateChatMessage(
chatMessageObject
)
this.ValidateTextStyle(
textStyleObject
)
this.ValidateNumber(
visualTop,
"시각 영역 위쪽 좌표"
)
displayText :=
chatMessageObject.ToDisplayText()
textBlock :=
this.BuildMeasuredTextBlock(
displayText,
textStyleObject
)
textY :=
visualTop
+ this.CalculateTopExtent(
textStyleObject
)
return TextDrawCommand(
textBlock["Text"],
this.LeftMargin,
textY,
textStyleObject,
textBlock["Width"],
textBlock["Height"],
textBlock["LineHeight"],
this.TextLineGap
)
}
BuildCommand(
chatMessageObject,
textStyleObject,
lineIndex := 0
)
{
this.ValidateLineIndex(
lineIndex
)
visualTop :=
this.TopMargin
+ (
lineIndex
* this.MinimumLineHeight
)
return this.BuildCommandAtVisualTop(
chatMessageObject,
textStyleObject,
visualTop
)
}
BuildCommands(
messageObjects,
textStyleObject
)
{
if (Type(messageObjects) != "Array")
{
throw TypeError(
"TextLayout에는 메시지 배열이 필요합니다."
)
}
this.ValidateTextStyle(
textStyleObject
)
drawCommands := []
currentVisualTop := this.TopMargin
for chatMessageObject in messageObjects
{
drawCommandObject :=
this.BuildCommandAtVisualTop(
chatMessageObject,
textStyleObject,
currentVisualTop
)
drawCommands.Push(
drawCommandObject
)
currentVisualTop +=
this.CalculateLineAdvance(
drawCommandObject
)
}
return drawCommands
}
BuildMeasuredTextBlock(
text,
textStyleObject
)
{
wrappedLines :=
this.WrapTextToLines(
text,
textStyleObject
)
maximumMeasuredWidth := 0
maximumLineHeight := 0
for lineText in wrappedLines
{
measurementText :=
lineText = ""
? " "
: lineText
lineMeasurement :=
this.TextMeasurer.Measure(
measurementText,
textStyleObject
)
maximumMeasuredWidth :=
Max(
maximumMeasuredWidth,
lineMeasurement["Width"]
)
maximumLineHeight :=
Max(
maximumLineHeight,
lineMeasurement["Height"]
)
}
if (maximumLineHeight <= 0)
{
fallbackMeasurement :=
this.TextMeasurer.Measure(
"가",
textStyleObject
)
maximumLineHeight :=
fallbackMeasurement["Height"]
}
lineCount :=
wrappedLines.Length
measuredHeight :=
(
maximumLineHeight
* lineCount
)
+ (
this.TextLineGap
* Max(
0,
lineCount - 1
)
)
return Map(
"Text", this.JoinLines(wrappedLines),
"Width", maximumMeasuredWidth,
"Height", measuredHeight,
"LineHeight", maximumLineHeight,
"LineCount", lineCount
)
}
WrapTextToLines(
text,
textStyleObject
)
{
normalizedText :=
StrReplace(
text,
"`r`n",
"`n"
)
normalizedText :=
StrReplace(
normalizedText,
"`r",
"`n"
)
paragraphTexts :=
StrSplit(
normalizedText,
"`n"
)
wrappedLines := []
for paragraphText in paragraphTexts
{
paragraphLines :=
this.WrapParagraph(
paragraphText,
textStyleObject
)
for lineText in paragraphLines
{
wrappedLines.Push(
lineText
)
}
}
if (wrappedLines.Length = 0)
{
wrappedLines.Push("")
}
return wrappedLines
}
WrapParagraph(
paragraphText,
textStyleObject
)
{
if (
this.MaximumTextWidth <= 0
|| paragraphText = ""
)
{
return [paragraphText]
}
paragraphMeasurement :=
this.TextMeasurer.Measure(
paragraphText,
textStyleObject
)
if (
paragraphMeasurement["Width"]
<= this.MaximumTextWidth
)
{
return [paragraphText]
}
wrappedLines := []
currentLine := ""
wordParts :=
StrSplit(
paragraphText,
A_Space
)
for wordText in wordParts
{
if (wordText = "")
continue
candidateText :=
currentLine = ""
? wordText
: currentLine " " wordText
candidateMeasurement :=
this.TextMeasurer.Measure(
candidateText,
textStyleObject
)
if (
candidateMeasurement["Width"]
<= this.MaximumTextWidth
)
{
currentLine :=
candidateText
continue
}
if (currentLine != "")
{
wrappedLines.Push(
currentLine
)
currentLine := ""
}
wordMeasurement :=
this.TextMeasurer.Measure(
wordText,
textStyleObject
)
if (
wordMeasurement["Width"]
<= this.MaximumTextWidth
)
{
currentLine :=
wordText
continue
}
longTokenLines :=
this.SplitLongToken(
wordText,
textStyleObject
)
for tokenLineIndex, tokenLineText
in longTokenLines
{
if (
tokenLineIndex
< longTokenLines.Length
)
{
wrappedLines.Push(
tokenLineText
)
}
else
{
currentLine :=
tokenLineText
}
}
}
if (currentLine != "")
{
wrappedLines.Push(
currentLine
)
}
if (wrappedLines.Length = 0)
{
wrappedLines.Push(
paragraphText
)
}
return wrappedLines
}
SplitLongToken(
tokenText,
textStyleObject
)
{
tokenLines := []
currentFragment := ""
Loop Parse tokenText
{
currentCharacter :=
A_LoopField
candidateFragment :=
currentFragment
. currentCharacter
candidateMeasurement :=
this.TextMeasurer.Measure(
candidateFragment,
textStyleObject
)
if (
currentFragment != ""
&& candidateMeasurement["Width"]
> this.MaximumTextWidth
)
{
tokenLines.Push(
currentFragment
)
currentFragment :=
currentCharacter
}
else
{
currentFragment :=
candidateFragment
}
}
if (currentFragment != "")
{
tokenLines.Push(
currentFragment
)
}
if (tokenLines.Length = 0)
{
tokenLines.Push(
tokenText
)
}
return tokenLines
}
JoinLines(lines)
{
joinedText := ""
for lineIndex, lineText in lines
{
if (lineIndex > 1)
joinedText .= "`n"
joinedText .=
lineText
}
return joinedText
}
CalculateTopExtent(
textStyleObject
)
{
this.ValidateTextStyle(
textStyleObject
)
topExtent := 0
if (
textStyleObject.GlowEnabled
&& textStyleObject.GlowOpacity > 0
)
{
topExtent :=
Max(
topExtent,
Ceil(
textStyleObject.GlowSize
)
)
}
if (textStyleObject.OutlineEnabled)
{
topExtent :=
Max(
topExtent,
Ceil(
textStyleObject.OutlineSize
)
)
}
if (textStyleObject.ShadowEnabled)
{
topExtent :=
Max(
topExtent,
Max(
0,
-textStyleObject.ShadowOffsetY
)
)
}
return Ceil(
topExtent
)
}
CalculateBottomExtent(
textStyleObject
)
{
this.ValidateTextStyle(
textStyleObject
)
bottomExtent := 0
if (
textStyleObject.GlowEnabled
&& textStyleObject.GlowOpacity > 0
)
{
bottomExtent :=
Max(
bottomExtent,
Ceil(
textStyleObject.GlowSize
)
)
}
if (textStyleObject.OutlineEnabled)
{
bottomExtent :=
Max(
bottomExtent,
Ceil(
textStyleObject.OutlineSize
)
)
}
if (textStyleObject.ShadowEnabled)
{
bottomExtent :=
Max(
bottomExtent,
Max(
0,
textStyleObject.ShadowOffsetY
)
)
}
return Ceil(
bottomExtent
)
}
CalculateVisualHeight(
drawCommandObject
)
{
this.ValidateTextDrawCommand(
drawCommandObject
)
visualHeight :=
this.CalculateTopExtent(
drawCommandObject.Style
)
+ drawCommandObject.MeasuredHeight
+ this.CalculateBottomExtent(
drawCommandObject.Style
)
return Max(
this.MinimumLineHeight,
Ceil(
visualHeight
)
)
}
CalculateLineAdvance(
drawCommandObject
)
{
return (
this.CalculateVisualHeight(
drawCommandObject
)
+ this.LineGap
)
}
CalculateVisualTop(
drawCommandObject
)
{
this.ValidateTextDrawCommand(
drawCommandObject
)
return (
drawCommandObject.Y
- this.CalculateTopExtent(
drawCommandObject.Style
)
)
}
CalculateVisualBottom(
drawCommandObject
)
{
this.ValidateTextDrawCommand(
drawCommandObject
)
return (
drawCommandObject.Y
+ drawCommandObject.MeasuredHeight
+ this.CalculateBottomExtent(
drawCommandObject.Style
)
)
}
ValidateChatMessage(
chatMessageObject
)
{
if (!IsObject(chatMessageObject))
{
throw TypeError(
"TextLayout에는 ChatMessage 객체가 필요합니다."
)
}
if (
!HasMethod(
chatMessageObject,
"ToDisplayText"
)
)
{
throw TypeError(
"ChatMessage 객체에 ToDisplayText()가 없습니다."
)
}
}
ValidateTextStyle(
textStyleObject
)
{
if (
!IsObject(textStyleObject)
|| Type(textStyleObject)
!= "TextStyle"
)
{
throw TypeError(
"TextLayout에는 TextStyle 객체가 필요합니다."
)
}
}
ValidateTextDrawCommand(
drawCommandObject
)
{
if (
!IsObject(drawCommandObject)
|| Type(drawCommandObject)
!= "TextDrawCommand"
)
{
throw TypeError(
"TextLayout에는 TextDrawCommand 객체가 필요합니다."
)
}
}
ValidateLineIndex(
lineIndex
)
{
if (!IsNumber(lineIndex))
{
throw TypeError(
"줄 번호는 숫자여야 합니다."
)
}
if (
lineIndex < 0
|| Floor(lineIndex) != lineIndex
)
{
throw ValueError(
"줄 번호는 0 이상의 정수여야 합니다.",
-1,
lineIndex
)
}
}
ValidateNumber(
value,
valueName
)
{
if (!IsNumber(value))
{
throw TypeError(
valueName "은 숫자여야 합니다."
)
}
}
ValidatePositiveNumber(
value,
valueName
)
{
this.ValidateNumber(
value,
valueName
)
if (value <= 0)
{
throw ValueError(
valueName "은 0보다 커야 합니다.",
-1,
value
)
}
}
ValidateNonNegativeNumber(
value,
valueName
)
{
this.ValidateNumber(
value,
valueName
)
if (value < 0)
{
throw ValueError(
valueName "은 0 이상이어야 합니다.",
-1,
value
)
}
}
}
🟡 Engine/Pipeline/ViewportLayout.ahk — 화면 높이에 맞춰 보이는 메시지 묶음 결정 #
;============================================================
; ViewportLayout
;
; Build 2.8.3
;
; 이미 측정된 TextDrawCommand 배열을 화면 높이에 맞춘다.
;
; 동작:
; - Bottom 모드: 기존 방식, 최신 메시지를 화면 아래쪽에 고정
; - Top 모드: 첫 번째 표시 메시지를 항상 TopMargin에 고정
; - Top 모드에서도 화면에 들어가는 최신 메시지 묶음을 유지
; - 여러 줄 메시지의 실제 높이를 그대로 사용
;
; 포함 순서:
; MessagePipeline/TextLayout을 먼저 Include한 뒤 사용한다.
;============================================================
class ViewportLayout
{
__New(
textLayoutObject,
viewportHeight,
topMargin := 25,
bottomMargin := 25,
alignmentMode := "Bottom"
)
{
if (!IsObject(textLayoutObject))
{
throw TypeError(
"ViewportLayout에는 TextLayout 객체가 필요합니다."
)
}
if (
!HasMethod(
textLayoutObject,
"CalculateVisualHeight"
)
|| !HasMethod(
textLayoutObject,
"CalculateTopExtent"
)
)
{
throw TypeError(
"ViewportLayout에서 사용할 수 없는 TextLayout 객체입니다."
)
}
this.ValidatePositiveNumber(
viewportHeight,
"Viewport 높이"
)
this.ValidateNonNegativeNumber(
topMargin,
"Viewport 위쪽 여백"
)
this.ValidateNonNegativeNumber(
bottomMargin,
"Viewport 아래쪽 여백"
)
if (
topMargin + bottomMargin
>= viewportHeight
)
{
throw ValueError(
"Viewport 여백의 합은 높이보다 작아야 합니다."
)
}
this.ValidateAlignmentMode(
alignmentMode
)
this.TextLayout := textLayoutObject
this.ViewportHeight := viewportHeight
this.TopMargin := topMargin
this.BottomMargin := bottomMargin
this.AlignmentMode := alignmentMode
}
SetAlignmentMode(
alignmentMode
)
{
this.ValidateAlignmentMode(
alignmentMode
)
this.AlignmentMode :=
alignmentMode
}
Apply(drawCommands)
{
if (
this.AlignmentMode = "Top"
)
{
return this.ApplyTopAnchored(
drawCommands
)
}
return this.ApplyBottomAnchored(
drawCommands
)
}
ApplyBottomAnchored(drawCommands)
{
if (Type(drawCommands) != "Array")
{
throw TypeError(
"ViewportLayout에는 TextDrawCommand 배열이 필요합니다."
)
}
visibleCommands := []
if (drawCommands.Length = 0)
{
return visibleCommands
}
currentVisualBottom :=
this.ViewportHeight
- this.BottomMargin
Loop drawCommands.Length
{
commandIndex :=
drawCommands.Length
- A_Index
+ 1
sourceCommand :=
drawCommands[
commandIndex
]
this.ValidateTextDrawCommand(
sourceCommand
)
visualHeight :=
this.TextLayout.CalculateVisualHeight(
sourceCommand
)
candidateVisualTop :=
currentVisualBottom
- visualHeight
if (
candidateVisualTop
< this.TopMargin
)
{
; 최신 메시지 하나가 화면보다 큰 경우에도
; 아래쪽은 보이도록 마지막 메시지 하나는 유지한다.
if (visibleCommands.Length = 0)
{
visibleCommands.InsertAt(
1,
this.CloneAtVisualTop(
sourceCommand,
candidateVisualTop
)
)
}
break
}
visibleCommands.InsertAt(
1,
this.CloneAtVisualTop(
sourceCommand,
candidateVisualTop
)
)
currentVisualBottom :=
candidateVisualTop
- this.TextLayout.LineGap
}
return visibleCommands
}
ApplyTopAnchored(drawCommands)
{
if (Type(drawCommands) != "Array")
{
throw TypeError(
"ViewportLayout에는 TextDrawCommand 배열이 필요합니다."
)
}
visibleCommands := []
if (drawCommands.Length = 0)
{
return visibleCommands
}
availableVisualHeight :=
this.ViewportHeight
- this.TopMargin
- this.BottomMargin
requiredVisualHeight := 0
selectedCount := 0
firstVisibleIndex :=
drawCommands.Length
; 최신 메시지부터 거꾸로 계산하여
; 화면에 들어가는 최신 메시지 묶음을 선택한다.
Loop drawCommands.Length
{
commandIndex :=
drawCommands.Length
- A_Index
+ 1
sourceCommand :=
drawCommands[
commandIndex
]
this.ValidateTextDrawCommand(
sourceCommand
)
visualHeight :=
this.TextLayout.CalculateVisualHeight(
sourceCommand
)
candidateRequiredHeight :=
requiredVisualHeight
+ visualHeight
if (selectedCount > 0)
{
candidateRequiredHeight +=
this.TextLayout.LineGap
}
if (
candidateRequiredHeight
> availableVisualHeight
)
{
; 최신 메시지 하나가 화면보다 큰 경우에도
; 해당 메시지 하나는 위쪽부터 표시한다.
if (selectedCount = 0)
{
firstVisibleIndex :=
commandIndex
selectedCount := 1
}
break
}
requiredVisualHeight :=
candidateRequiredHeight
firstVisibleIndex :=
commandIndex
selectedCount += 1
}
currentVisualTop :=
this.TopMargin
Loop selectedCount
{
commandIndex :=
firstVisibleIndex
+ A_Index
- 1
sourceCommand :=
drawCommands[
commandIndex
]
visibleCommands.Push(
this.CloneAtVisualTop(
sourceCommand,
currentVisualTop
)
)
currentVisualTop +=
this.TextLayout.CalculateVisualHeight(
sourceCommand
)
if (A_Index < selectedCount)
{
currentVisualTop +=
this.TextLayout.LineGap
}
}
return visibleCommands
}
CloneAtVisualTop(
sourceCommand,
visualTop
)
{
textY :=
visualTop
+ this.TextLayout.CalculateTopExtent(
sourceCommand.Style
)
return TextDrawCommand(
sourceCommand.Text,
sourceCommand.X,
textY,
sourceCommand.Style,
sourceCommand.MeasuredWidth,
sourceCommand.MeasuredHeight,
sourceCommand.LineHeight,
sourceCommand.TextLineGap
)
}
GetVisibleCount(drawCommands)
{
return this.Apply(
drawCommands
).Length
}
ValidateTextDrawCommand(
drawCommandObject
)
{
if (
!IsObject(drawCommandObject)
|| Type(drawCommandObject)
!= "TextDrawCommand"
)
{
throw TypeError(
"ViewportLayout에는 TextDrawCommand 객체가 필요합니다."
)
}
}
ValidateAlignmentMode(
alignmentMode
)
{
if (
alignmentMode != "Top"
&& alignmentMode != "Bottom"
)
{
throw ValueError(
"Viewport 정렬 방식은 Top 또는 Bottom이어야 합니다.",
-1,
alignmentMode
)
}
}
ValidatePositiveNumber(
value,
valueName
)
{
if (!IsNumber(value))
{
throw TypeError(
valueName "은 숫자여야 합니다."
)
}
if (value <= 0)
{
throw ValueError(
valueName "은 0보다 커야 합니다.",
-1,
value
)
}
}
ValidateNonNegativeNumber(
value,
valueName
)
{
if (!IsNumber(value))
{
throw TypeError(
valueName "은 숫자여야 합니다."
)
}
if (value < 0)
{
throw ValueError(
valueName "은 0 이상이어야 합니다.",
-1,
value
)
}
}
}
🟡 Engine/Pipeline/VisibleMessageWindowSizer.ahk — 표시 개수에 맞춰 송출 창 높이 계산 #
;============================================================
; VisibleMessageWindowSizer
;
; Build 2.6
;
; 표시 메시지 개수에 맞춰 투명 오버레이 창 높이를 계산한다.
;
; 핵심:
; - 창의 X/Y/가로폭은 바꾸지 않는다.
; - 위쪽 좌표(Y)는 고정한다.
; - 표시 개수가 줄면 창 아래쪽만 위로 올라온다.
; - 모든 스타일 중 가장 높은 한 줄 높이를 기준으로 계산한다.
;============================================================
class VisibleMessageWindowSizer
{
__New(
textLayoutObject,
topMargin,
bottomMargin
)
{
if (!IsObject(textLayoutObject))
{
throw TypeError(
"VisibleMessageWindowSizer에는 TextLayout 객체가 필요합니다."
)
}
this.ValidateNonNegativeInteger(
topMargin,
"위쪽 여백"
)
this.ValidateNonNegativeInteger(
bottomMargin,
"아래쪽 여백"
)
this.TextLayout :=
textLayoutObject
this.TopMargin :=
topMargin
this.BottomMargin :=
bottomMargin
}
CalculateHeight(
visibleMessageLimit,
styleObjects,
sampleText :=
"[사용자00] 최근 메시지 00 - 한 줄 표시 테스트"
)
{
this.ValidatePositiveInteger(
visibleMessageLimit,
"표시 메시지 개수"
)
maximumSlotHeight :=
this.CalculateMaximumSlotHeight(
styleObjects,
sampleText
)
gapCount :=
Max(
0,
visibleMessageLimit - 1
)
return (
this.TopMargin
+ this.BottomMargin
+ (
visibleMessageLimit
* maximumSlotHeight
)
+ (
gapCount
* this.TextLayout.LineGap
)
)
}
CalculateMaximumSlotHeight(
styleObjects,
sampleText
)
{
if (Type(styleObjects) != "Map")
{
throw TypeError(
"스타일 Map이 필요합니다."
)
}
if (styleObjects.Count = 0)
{
throw ValueError(
"스타일 Map이 비어 있습니다."
)
}
maximumSlotHeight := 0
for styleName, textStyleObject
in styleObjects
{
measuredBlock :=
this.TextLayout.BuildMeasuredTextBlock(
sampleText,
textStyleObject
)
sampleCommand :=
TextDrawCommand(
measuredBlock["Text"],
0,
0,
textStyleObject,
measuredBlock["Width"],
measuredBlock["Height"],
measuredBlock["LineHeight"],
this.TextLayout.TextLineGap
)
maximumSlotHeight :=
Max(
maximumSlotHeight,
this.TextLayout.CalculateVisualHeight(
sampleCommand
)
)
}
return maximumSlotHeight
}
ValidatePositiveInteger(
value,
valueName
)
{
if (
!IsNumber(value)
|| value <= 0
|| Floor(value) != value
)
{
throw ValueError(
valueName
. "은 1 이상의 정수여야 합니다.",
-1,
value
)
}
}
ValidateNonNegativeInteger(
value,
valueName
)
{
if (
!IsNumber(value)
|| value < 0
|| Floor(value) != value
)
{
throw ValueError(
valueName
. "은 0 이상의 정수여야 합니다.",
-1,
value
)
}
}
}
🟣 Engine · Providers — 외부 서비스 #
🟣 Engine/Providers/YoutubeProvider.ahk — 유튜브 라이브 채팅 가져오기 (API 폴링) #
#Include ..\Utilities\Json.ahk
;============================================================
; YoutubeProvider
;
; 역할:
; - 사용자가 붙여넣은 라이브 URL에서 영상 ID를 찾는다.
; - 영상 ID로 현재 활성화된 liveChatId를 조회한다.
; - liveChatId로 채팅 메시지를 폴링(Poll)한다.
;
; 이 클래스는 GUI를 전혀 모른다.
; 네트워크 요청과 응답 해석만 담당한다.
;============================================================
class YoutubeProvider
{
__New(apiKey)
{
if (
Type(apiKey) != "String"
|| Trim(apiKey) = ""
)
{
throw ValueError(
"유튜브 API 키가 필요합니다."
)
}
this.ApiKey := apiKey
this.LiveChatId := ""
this.NextPageToken := ""
; 연결/전송/수신 각 단계 최대 대기 시간(ms).
; GUI가 무한정 멈추지 않도록 상한을 둔다.
this.RequestTimeoutMs := 5000
}
; ------------------------------------------------------------
; 1단계: URL 또는 순수 영상 ID에서 영상 ID만 뽑아낸다.
; ------------------------------------------------------------
ExtractVideoId(urlOrId)
{
trimmedInput := Trim(urlOrId)
if (trimmedInput = "")
{
throw ValueError(
"라이브 URL 또는 영상 ID를 입력하세요."
)
}
; watch?v=VIDEOID 형태
if (
RegExMatch(
trimmedInput,
"[?&]v=([\w-]{11})",
&matchResult
)
)
{
return matchResult[1]
}
; youtu.be/VIDEOID 형태
if (
RegExMatch(
trimmedInput,
"youtu\.be/([\w-]{11})",
&matchResult
)
)
{
return matchResult[1]
}
; youtube.com/live/VIDEOID 형태
if (
RegExMatch(
trimmedInput,
"youtube\.com/live/([\w-]{11})",
&matchResult
)
)
{
return matchResult[1]
}
; URL이 아니라 영상 ID를 그대로 붙여넣은 경우
; (유튜브 영상 ID는 항상 11자)
if (
RegExMatch(
trimmedInput,
"^[\w-]{11}$"
)
)
{
return trimmedInput
}
throw ValueError(
"라이브 URL에서 영상 ID를 찾을 수 없습니다.`n"
. "입력값: " . trimmedInput
)
}
; ------------------------------------------------------------
; 2단계: 영상 ID로 활성 liveChatId를 조회한다.
; 성공하면 this.LiveChatId에 저장하고 그 값을 반환한다.
; ------------------------------------------------------------
ResolveLiveChatId(videoId)
{
requestUrl :=
"https://www.googleapis.com/youtube/v3/videos"
. "?part=liveStreamingDetails,snippet"
. "&id=" . videoId
. "&key=" . this.ApiKey
responseText :=
this.SendGetRequest(requestUrl)
responseData :=
Json.Parse(responseText)
if (
!responseData.Has("items")
|| responseData["items"].Length = 0
)
{
throw ValueError(
"해당 영상을 찾을 수 없습니다.`n"
. "URL 또는 영상 ID를 다시 확인하세요."
)
}
videoItem := responseData["items"][1]
if (!videoItem.Has("liveStreamingDetails"))
{
throw ValueError(
"이 영상은 라이브 방송이 아니거나`n"
. "라이브가 이미 종료되었습니다."
)
}
liveDetails :=
videoItem["liveStreamingDetails"]
if (
!liveDetails.Has("activeLiveChatId")
|| Trim(liveDetails["activeLiveChatId"]) = ""
)
{
throw ValueError(
"이 라이브 방송은 채팅이 꺼져있거나`n"
. "이미 종료된 상태입니다."
)
}
this.LiveChatId :=
liveDetails["activeLiveChatId"]
this.NextPageToken := ""
titleText := ""
if (
videoItem.Has("snippet")
&& videoItem["snippet"].Has("title")
)
{
titleText :=
videoItem["snippet"]["title"]
}
return Map(
"LiveChatId", this.LiveChatId,
"Title", titleText
)
}
; ------------------------------------------------------------
; 3단계: 채팅 메시지를 한 번 폴링한다.
; 반환값: Map("Messages", [...], "PollingIntervalMillis", 숫자)
; ------------------------------------------------------------
Poll()
{
if (Trim(this.LiveChatId) = "")
{
throw ValueError(
"먼저 ResolveLiveChatId()로 연결해야 합니다."
)
}
requestUrl :=
"https://www.googleapis.com/youtube/v3/liveChat/messages"
. "?liveChatId=" . this.LiveChatId
. "&part=snippet,authorDetails"
. "&key=" . this.ApiKey
if (Trim(this.NextPageToken) != "")
{
requestUrl .=
"&pageToken=" . this.NextPageToken
}
responseText :=
this.SendGetRequest(requestUrl)
responseData :=
Json.Parse(responseText)
if (responseData.Has("pageToken"))
{
this.NextPageToken :=
responseData["pageToken"]
}
if (responseData.Has("nextPageToken"))
{
this.NextPageToken :=
responseData["nextPageToken"]
}
pollingIntervalMillis := 5000
if (responseData.Has("pollingIntervalMillis"))
{
pollingIntervalMillis :=
responseData["pollingIntervalMillis"]
}
parsedMessages := []
if (responseData.Has("items"))
{
for chatItem in responseData["items"]
{
parsedMessages.Push(
this.ParseChatItem(chatItem)
)
}
}
return Map(
"Messages", parsedMessages,
"PollingIntervalMillis", pollingIntervalMillis
)
}
; ------------------------------------------------------------
; 채팅 항목 하나를 프로그램 내부에서 쓰기 좋은 형태로 변환.
; ------------------------------------------------------------
ParseChatItem(chatItem)
{
authorName := "알 수 없음"
messageText := ""
messageKind := "Normal"
channelId := ""
if (chatItem.Has("authorDetails"))
{
authorDetails :=
chatItem["authorDetails"]
if (authorDetails.Has("displayName"))
{
authorName :=
authorDetails["displayName"]
}
if (authorDetails.Has("channelId"))
{
channelId :=
authorDetails["channelId"]
}
}
if (chatItem.Has("snippet"))
{
snippetData :=
chatItem["snippet"]
if (snippetData.Has("displayMessage"))
{
messageText :=
snippetData["displayMessage"]
}
if (snippetData.Has("type"))
{
messageKind :=
this.ResolveMessageKind(
snippetData["type"]
)
}
}
return Map(
"Author", authorName,
"Message", messageText,
"Type", messageKind,
"ChannelId", channelId
)
}
; 유튜브가 주는 원본 type 값을
; 기존 렌더러가 이해하는 종류(Normal/Donation/Notice 등)로 옮긴다.
ResolveMessageKind(youtubeTypeText)
{
if (youtubeTypeText = "superChatEvent")
{
return "Donation"
}
if (youtubeTypeText = "superStickerEvent")
{
return "Donation"
}
if (youtubeTypeText = "newSponsorEvent")
{
return "Notice"
}
if (youtubeTypeText = "membershipGiftingEvent")
{
return "Notice"
}
if (youtubeTypeText = "textMessageEvent")
{
return "Normal"
}
return "Normal"
}
; ------------------------------------------------------------
; 공통 GET 요청.
; 타임아웃을 짧게 유지해 GUI가 오래 멈추지 않게 한다.
; ------------------------------------------------------------
SendGetRequest(requestUrl)
{
httpRequest :=
ComObject(
"WinHttp.WinHttpRequest.5.1"
)
httpRequest.SetTimeouts(
this.RequestTimeoutMs,
this.RequestTimeoutMs,
this.RequestTimeoutMs,
this.RequestTimeoutMs
)
httpRequest.Open("GET", requestUrl, false)
httpRequest.Send()
if (httpRequest.Status != 200)
{
throw ValueError(
"유튜브 API 요청 실패`n"
. "응답 코드: " . httpRequest.Status
. "`n" . SubStr(httpRequest.ResponseText, 1, 300)
)
}
return httpRequest.ResponseText
}
}
🔴 Engine · Rendering — 화면에 그리기 #
🔴 Engine/Rendering/TextStyle.ahk — 글꼴·색·효과를 담는 스타일 데이터 #
;============================================================
; TextStyle
;
; 텍스트 출력 스타일 데이터 객체
;
; Build 1.9.1
; - 메시지 사각 배경 없음
; - Glow 효과 추가
;============================================================
class TextStyle
{
__New(
fontName,
fontSize,
fontWeight,
color,
opacity,
shadowEnabled,
shadowColor,
shadowOffsetX,
shadowOffsetY,
outlineEnabled,
outlineColor,
outlineSize,
glowEnabled := false,
glowColor := "FFFFFF",
glowSize := 0,
glowOpacity := 0
)
{
this.ValidateRequiredText(fontName, "FontName")
this.ValidateIntegerRange(fontSize, 1, 300, "FontSize")
this.ValidateIntegerRange(fontWeight, 1, 1000, "FontWeight")
this.ValidateColor(color, "Color")
this.ValidateIntegerRange(opacity, 0, 255, "Opacity")
this.ValidateBoolean(shadowEnabled, "ShadowEnabled")
this.ValidateColor(shadowColor, "ShadowColor")
this.ValidateInteger(shadowOffsetX, "ShadowOffsetX")
this.ValidateInteger(shadowOffsetY, "ShadowOffsetY")
this.ValidateBoolean(outlineEnabled, "OutlineEnabled")
this.ValidateColor(outlineColor, "OutlineColor")
this.ValidateIntegerRange(outlineSize, 0, 20, "OutlineSize")
this.ValidateBoolean(glowEnabled, "GlowEnabled")
this.ValidateColor(glowColor, "GlowColor")
this.ValidateIntegerRange(glowSize, 0, 12, "GlowSize")
this.ValidateIntegerRange(glowOpacity, 0, 255, "GlowOpacity")
this.FontName := Trim(fontName)
this.FontSize := fontSize
this.FontWeight := fontWeight
this.Color := this.NormalizeColor(color)
this.Opacity := opacity
this.ShadowEnabled := !!shadowEnabled
this.ShadowColor := this.NormalizeColor(shadowColor)
this.ShadowOffsetX := shadowOffsetX
this.ShadowOffsetY := shadowOffsetY
this.OutlineEnabled := !!outlineEnabled
this.OutlineColor := this.NormalizeColor(outlineColor)
this.OutlineSize := outlineSize
this.GlowEnabled := !!glowEnabled
this.GlowColor := this.NormalizeColor(glowColor)
this.GlowSize := glowSize
this.GlowOpacity := glowOpacity
}
ToDebugText()
{
resultText := ""
resultText .= "Font: " this.FontName " / " this.FontSize "pt / " this.FontWeight "`n"
resultText .= "Color: #" this.Color " / Opacity " this.Opacity "`n"
resultText .= "Shadow: " (this.ShadowEnabled ? "On" : "Off")
resultText .= " / #" this.ShadowColor
resultText .= " / " this.ShadowOffsetX ", " this.ShadowOffsetY "`n"
resultText .= "Outline: " (this.OutlineEnabled ? "On" : "Off")
resultText .= " / #" this.OutlineColor
resultText .= " / " this.OutlineSize "px`n"
resultText .= "Glow: " (this.GlowEnabled ? "On" : "Off")
resultText .= " / #" this.GlowColor
resultText .= " / " this.GlowSize "px"
resultText .= " / Opacity " this.GlowOpacity
return resultText
}
NormalizeColor(colorValue)
{
normalizedColor := StrUpper(Trim(colorValue))
if (SubStr(normalizedColor, 1, 1) = "#")
normalizedColor := SubStr(normalizedColor, 2)
return normalizedColor
}
ValidateRequiredText(value, valueName)
{
if (Type(value) != "String")
throw TypeError(valueName "은 문자열이어야 합니다.")
if (Trim(value) = "")
throw ValueError(valueName "은 비어 있을 수 없습니다.")
}
ValidateBoolean(value, valueName)
{
if (value != 0 && value != 1)
throw ValueError(valueName "은 True 또는 False여야 합니다.", -1, value)
}
ValidateInteger(value, valueName)
{
if (!IsNumber(value))
throw TypeError(valueName "은 숫자여야 합니다.")
if (Floor(value) != value)
throw ValueError(valueName "은 정수여야 합니다.", -1, value)
}
ValidateIntegerRange(value, minimumValue, maximumValue, valueName)
{
this.ValidateInteger(value, valueName)
if (value < minimumValue || value > maximumValue)
{
throw ValueError(
valueName "은 " minimumValue "부터 " maximumValue " 사이여야 합니다.",
-1,
value
)
}
}
ValidateColor(value, valueName)
{
if (Type(value) != "String")
throw TypeError(valueName "은 RGB 문자열이어야 합니다.")
normalizedColor := this.NormalizeColor(value)
if (!RegExMatch(normalizedColor, "^[0-9A-F]{6}$"))
throw ValueError(valueName "은 RRGGBB 형식이어야 합니다.", -1, value)
}
}
🔴 Engine/Rendering/TextDrawCommand.ahk — 렌더러에 넘기는 그리기 명령 데이터 #
;============================================================
; TextDrawCommand
;
; Renderer가 텍스트를 그리는 데 필요한 명령 데이터
;
; Build 2.0
; - 여러 줄 텍스트 지원
; - 실제 줄 높이와 줄 내부 간격 보관
;============================================================
class TextDrawCommand
{
__New(
text,
x,
y,
textStyleObject,
measuredWidth := 0,
measuredHeight := 0,
lineHeight := 0,
textLineGap := 0
)
{
if (Type(text) != "String")
{
throw TypeError(
"TextDrawCommand.Text는 문자열이어야 합니다."
)
}
if (!IsNumber(x))
{
throw TypeError(
"TextDrawCommand.X는 숫자여야 합니다."
)
}
if (!IsNumber(y))
{
throw TypeError(
"TextDrawCommand.Y는 숫자여야 합니다."
)
}
if (
!IsObject(textStyleObject)
|| Type(textStyleObject) != "TextStyle"
)
{
throw TypeError(
"TextDrawCommand.Style은 TextStyle 객체여야 합니다."
)
}
this.ValidateNonNegativeNumber(
measuredWidth,
"MeasuredWidth"
)
this.ValidateNonNegativeNumber(
measuredHeight,
"MeasuredHeight"
)
this.ValidateNonNegativeNumber(
lineHeight,
"LineHeight"
)
this.ValidateNonNegativeNumber(
textLineGap,
"TextLineGap"
)
this.Text := text
this.X := x
this.Y := y
this.Style := textStyleObject
this.MeasuredWidth := measuredWidth
this.MeasuredHeight := measuredHeight
this.LineHeight :=
lineHeight > 0
? lineHeight
: measuredHeight
this.TextLineGap :=
textLineGap
}
HasMeasurement()
{
return (
this.MeasuredWidth > 0
&& this.MeasuredHeight > 0
)
}
GetLines()
{
normalizedText :=
StrReplace(
this.Text,
"`r`n",
"`n"
)
normalizedText :=
StrReplace(
normalizedText,
"`r",
"`n"
)
return StrSplit(
normalizedText,
"`n"
)
}
GetLineCount()
{
return this.GetLines().Length
}
GetLineAdvance()
{
return (
this.LineHeight
+ this.TextLineGap
)
}
ToDebugText()
{
resultText := ""
resultText .=
"Text: "
. this.Text
. "`n"
resultText .=
"Position: "
. this.X
. ", "
. this.Y
. "`n"
resultText .=
"Measured: "
. this.MeasuredWidth
. " x "
. this.MeasuredHeight
. "`n"
resultText .=
"Lines: "
. this.GetLineCount()
. " / LineHeight "
. this.LineHeight
. " / Gap "
. this.TextLineGap
. "`n"
resultText .=
this.Style.ToDebugText()
return resultText
}
ValidateNonNegativeNumber(
value,
valueName
)
{
if (!IsNumber(value))
{
throw TypeError(
valueName "은 숫자여야 합니다."
)
}
if (value < 0)
{
throw ValueError(
valueName "은 0 이상이어야 합니다.",
-1,
value
)
}
}
}
🔴 Engine/Rendering/GdiTextMeasurer.ahk — GDI로 글자 폭·높이 측정 #
;============================================================
; GdiTextMeasurer
;
; OverlayRenderer와 동일한 GDI 글꼴을 생성하여
; 문자열의 실제 폭과 높이를 측정한다.
;
; 화면 출력과 메시지 위치 계산은 담당하지 않는다.
;============================================================
class GdiTextMeasurer
{
;--------------------------------------------------------
; Measure()
; 현재 스타일로 텍스트의 실제 GDI 크기를 측정한다.
;
; 반환:
; Map(
; "Width", 실제 폭,
; "Height", 실제 높이
; )
;--------------------------------------------------------
Measure(text, textStyleObject)
{
this.ValidateText(text)
this.ValidateTextStyle(textStyleObject)
measurementDeviceContext := DllCall(
"gdi32\CreateCompatibleDC",
"Ptr", 0,
"Ptr"
)
if (!measurementDeviceContext)
{
throw Error(
"텍스트 측정용 GDI Device Context를 만들 수 없습니다."
)
}
fontHandle := 0
previousFontHandle := 0
try
{
fontHandle :=
this.CreateFont(
textStyleObject
)
if (!fontHandle)
{
throw Error(
"텍스트 측정용 GDI 글꼴을 만들 수 없습니다."
)
}
previousFontHandle := DllCall(
"gdi32\SelectObject",
"Ptr", measurementDeviceContext,
"Ptr", fontHandle,
"Ptr"
)
if (!previousFontHandle)
{
throw Error(
"텍스트 측정용 글꼴을 Device Context에 선택할 수 없습니다."
)
}
sizeBuffer := Buffer(8, 0)
measurementSucceeded := DllCall(
"gdi32\GetTextExtentPoint32W",
"Ptr", measurementDeviceContext,
"Str", text,
"Int", StrLen(text),
"Ptr", sizeBuffer,
"Int"
)
if (!measurementSucceeded)
{
throw Error(
"GDI 텍스트 크기 측정에 실패했습니다."
)
}
measuredWidth :=
NumGet(
sizeBuffer,
0,
"Int"
)
measuredHeight :=
NumGet(
sizeBuffer,
4,
"Int"
)
return Map(
"Width", measuredWidth,
"Height", measuredHeight
)
}
finally
{
if (
previousFontHandle
&& measurementDeviceContext
)
{
DllCall(
"gdi32\SelectObject",
"Ptr", measurementDeviceContext,
"Ptr", previousFontHandle,
"Ptr"
)
}
if (fontHandle)
{
DllCall(
"gdi32\DeleteObject",
"Ptr", fontHandle,
"Int"
)
}
if (measurementDeviceContext)
{
DllCall(
"gdi32\DeleteDC",
"Ptr", measurementDeviceContext,
"Int"
)
}
}
}
;--------------------------------------------------------
; CreateFont()
; OverlayRenderer와 같은 조건으로 GDI 글꼴을 생성한다.
;--------------------------------------------------------
CreateFont(textStyleObject)
{
fontHeight := -DllCall(
"kernel32\MulDiv",
"Int", textStyleObject.FontSize,
"Int", A_ScreenDPI,
"Int", 72,
"Int"
)
return DllCall(
"gdi32\CreateFontW",
"Int", fontHeight,
"Int", 0,
"Int", 0,
"Int", 0,
"Int", textStyleObject.FontWeight,
"UInt", 0,
"UInt", 0,
"UInt", 0,
"UInt", 1,
"UInt", 0,
"UInt", 0,
"UInt", 5,
"UInt", 0,
"Str", textStyleObject.FontName,
"Ptr"
)
}
;--------------------------------------------------------
; ValidateText()
;--------------------------------------------------------
ValidateText(text)
{
if (Type(text) != "String")
{
throw TypeError(
"GdiTextMeasurer.Text는 문자열이어야 합니다."
)
}
}
;--------------------------------------------------------
; ValidateTextStyle()
;--------------------------------------------------------
ValidateTextStyle(textStyleObject)
{
if (
!IsObject(textStyleObject)
|| Type(textStyleObject) != "TextStyle"
)
{
throw TypeError(
"GdiTextMeasurer에는 TextStyle 객체가 필요합니다."
)
}
}
}
🔴 Engine/Rendering/LayeredOverlayRenderer.ahk — 투명 오버레이 렌더러 (픽셀별 알파 · 글로우 · 그림자 · 외곽선) #
;============================================================
; LayeredOverlayRenderer
;
; Build 2.8.2 - Layered Window Atomic Resize
;
; UpdateLayeredWindow + 32-bit PARGB를 사용한다.
; 색상 키(WinSetTransColor)는 사용하지 않는다.
;
; 지원 효과:
; - 투명 캔버스
; - 픽셀별 Alpha
; - Glow
; - Shadow
; - Outline
; - Main Text
;
; 렌더링 순서:
; Glow -> Shadow -> Outline -> Main Text
;============================================================
class LayeredOverlayRenderer
{
__New(targetWindowHandle, fontsFolderPath := "")
{
if (
!IsNumber(targetWindowHandle)
|| targetWindowHandle <= 0
)
{
throw ValueError(
"유효한 대상 창 HWND가 필요합니다.",
-1,
targetWindowHandle
)
}
this.TargetWindowHandle := targetWindowHandle
this.DrawCommands := []
this.GdiPlusToken := 0
this.GdiPlusStarted := false
; UpdateLayeredWindow가 위치/크기/그림을 한 번에 적용하도록
; 선택적 창 경계값을 보관한다.
this.BoundsOverrideEnabled := false
this.OverrideX := 0
this.OverrideY := 0
this.OverrideWidth := 0
this.OverrideHeight := 0
; 2026-07 추가: 채팅 없을 때 서서히 사라지는 기능용.
; 255=완전 불투명, 0=완전 투명.
this.WindowOpacityByte := 255
; 2026-07 추가: 시스템에 설치 안 된 외부 폰트 파일
; (Assets\Fonts 폴더)을 불러오기 위한 GDI+ 전용 폰트 모음.
this.PrivateFontCollection := 0
this.StartGdiPlus()
this.LoadPrivateFonts(
fontsFolderPath
)
}
__Delete()
{
; AutoHotkey 인터프리터의 객체 정리 시점에는
; GDI+ DLL 및 관련 객체의 종료 순서를 보장할 수 없다.
;
; 따라서 __Delete() 안에서는 GdiplusShutdown을 호출하지 않는다.
; 정상 실행 중에는 테스트 또는 Application이 Shutdown()을
; 명시적으로 호출한다.
}
Shutdown()
{
; 현재 렌더러에서는 GdiplusShutdown 호출 시
; 일부 환경에서 0xc0000005 접근 위반이 발생한다.
;
; 이 렌더러는 프로그램 실행 중 GDI+를 한 번만 시작하고
; 종료 직후 프로세스가 끝나므로, 여기서는 네이티브 종료
; 함수를 호출하지 않고 상태만 종료 처리한다.
;
; 남은 GDI+ 프로세스 리소스는 Windows가 프로세스 종료와
; 함께 회수한다. 기존 Shutdown() 호출 코드는 그대로 둔다.
this.GdiPlusToken := 0
this.GdiPlusStarted := false
}
SetWindowBounds(
windowX,
windowY,
windowWidth,
windowHeight
)
{
if (
!IsNumber(windowX)
|| !IsNumber(windowY)
)
{
throw TypeError(
"오버레이 X/Y 좌표는 숫자여야 합니다."
)
}
if (
!IsNumber(windowWidth)
|| !IsNumber(windowHeight)
|| windowWidth <= 0
|| windowHeight <= 0
)
{
throw ValueError(
"오버레이 폭과 높이는 1 이상이어야 합니다."
)
}
this.OverrideX := Round(windowX)
this.OverrideY := Round(windowY)
this.OverrideWidth := Round(windowWidth)
this.OverrideHeight := Round(windowHeight)
this.BoundsOverrideEnabled := true
}
SetDrawCommands(drawCommands)
{
if (Type(drawCommands) != "Array")
{
throw TypeError(
"LayeredOverlayRenderer에는 TextDrawCommand 배열이 필요합니다."
)
}
this.DrawCommands := drawCommands.Clone()
this.Render()
}
Clear()
{
this.DrawCommands := []
this.Render()
}
; 2026-07 추가: 창 전체의 투명도(0~255)를 설정하고 다시 그린다.
; 텍스트 하나하나의 Opacity(스타일)와는 별개로,
; 창 전체에 곱해지는 투명도라서 페이드 효과에 쓴다.
SetWindowOpacityByte(opacityByte)
{
clampedValue :=
Max(
0,
Min(
255,
Round(opacityByte)
)
)
if (clampedValue = this.WindowOpacityByte)
{
return
}
this.WindowOpacityByte := clampedValue
this.Render()
}
; 2026-07 추가:
; DIB Section의 픽셀 하나하나(BGRA 4바이트, PARGB 형식)에
; WindowOpacityByte/255를 곱해서 실제로 더 투명하게 굽는다.
; PARGB(사전 곱셈 알파)이므로 B/G/R/A 네 바이트 전부를
; 같은 비율로 줄여야 색이 깨지지 않는다.
;
; 4바이트를 한 번에 UInt로 읽고 써서 NumGet/NumPut 호출을
; 절반으로 줄였다. 그래도 해상도가 크면 시간이 걸릴 수 있어서,
; 완전 불투명(255)일 때는 아예 호출되지 않도록
; Render()에서 이미 걸러준다.
ApplyOpacityToPixels(
bitmapBitsPointer,
windowWidth,
windowHeight
)
{
fadeFactor :=
this.WindowOpacityByte / 255.0
totalPixelCount :=
windowWidth * windowHeight
Loop totalPixelCount
{
byteOffset :=
(A_Index - 1) * 4
pixelAddress :=
bitmapBitsPointer
+ byteOffset
originalValue :=
NumGet(
pixelAddress,
"UInt"
)
channel0 :=
Integer(
(originalValue & 0xFF)
* fadeFactor
)
channel1 :=
Integer(
((originalValue >> 8) & 0xFF)
* fadeFactor
)
channel2 :=
Integer(
((originalValue >> 16) & 0xFF)
* fadeFactor
)
channel3 :=
Integer(
((originalValue >> 24) & 0xFF)
* fadeFactor
)
scaledValue :=
channel0
| (channel1 << 8)
| (channel2 << 16)
| (channel3 << 24)
NumPut(
"UInt",
scaledValue,
pixelAddress
)
}
}
Render()
{
windowRectangle :=
this.GetWindowRectangle()
windowX :=
windowRectangle["X"]
windowY :=
windowRectangle["Y"]
windowWidth :=
windowRectangle["Width"]
windowHeight :=
windowRectangle["Height"]
if (
windowWidth <= 0
|| windowHeight <= 0
)
{
return
}
screenDeviceContext := DllCall(
"user32\GetDC",
"Ptr", 0,
"Ptr"
)
if (!screenDeviceContext)
{
throw Error(
"화면 Device Context를 얻을 수 없습니다."
)
}
memoryDeviceContext := 0
dibBitmapHandle := 0
previousBitmapHandle := 0
bitmapBitsPointer := 0
gdiPlusBitmap := 0
gdiPlusGraphics := 0
try
{
memoryDeviceContext := DllCall(
"gdi32\CreateCompatibleDC",
"Ptr", screenDeviceContext,
"Ptr"
)
if (!memoryDeviceContext)
{
throw Error(
"메모리 Device Context를 만들 수 없습니다."
)
}
bitmapInformation :=
this.CreateBitmapInformation(
windowWidth,
windowHeight
)
dibBitmapHandle := DllCall(
"gdi32\CreateDIBSection",
"Ptr", screenDeviceContext,
"Ptr", bitmapInformation,
"UInt", 0,
"Ptr*", &bitmapBitsPointer,
"Ptr", 0,
"UInt", 0,
"Ptr"
)
if (
!dibBitmapHandle
|| !bitmapBitsPointer
)
{
throw Error(
"32비트 DIB Section을 만들 수 없습니다."
)
}
previousBitmapHandle := DllCall(
"gdi32\SelectObject",
"Ptr", memoryDeviceContext,
"Ptr", dibBitmapHandle,
"Ptr"
)
if (!previousBitmapHandle)
{
throw Error(
"DIB Section을 메모리 DC에 선택할 수 없습니다."
)
}
stride :=
windowWidth * 4
pixelFormat32bppPARGB :=
0x000E200B
createBitmapStatus := DllCall(
"gdiplus\GdipCreateBitmapFromScan0",
"Int", windowWidth,
"Int", windowHeight,
"Int", stride,
"Int", pixelFormat32bppPARGB,
"Ptr", bitmapBitsPointer,
"Ptr*", &gdiPlusBitmap,
"Int"
)
this.ThrowIfGdiPlusFailed(
createBitmapStatus,
"GDI+ PARGB Bitmap 생성"
)
graphicsStatus := DllCall(
"gdiplus\GdipGetImageGraphicsContext",
"Ptr", gdiPlusBitmap,
"Ptr*", &gdiPlusGraphics,
"Int"
)
this.ThrowIfGdiPlusFailed(
graphicsStatus,
"GDI+ Graphics 생성"
)
this.ConfigureGraphics(
gdiPlusGraphics
)
clearStatus := DllCall(
"gdiplus\GdipGraphicsClear",
"Ptr", gdiPlusGraphics,
"UInt", 0x00000000,
"Int"
)
this.ThrowIfGdiPlusFailed(
clearStatus,
"투명 캔버스 초기화"
)
this.DrawAllCommands(
gdiPlusGraphics,
windowWidth,
windowHeight
)
DllCall(
"gdiplus\GdipFlush",
"Ptr", gdiPlusGraphics,
"Int", 0,
"Int"
)
; 2026-07 수정: 페이드(창 전체 투명도)를
; BLENDFUNCTION의 SourceConstantAlpha(DWM 힌트)가 아니라
; 실제 픽셀 데이터에 직접 곱해서 적용한다.
; 이유: OBS 창 캡처 방식에 따라 SourceConstantAlpha는
; 무시하고 창이 그린 원본 픽셀만 그대로 가져가는
; 경우가 있어서, 모니터 화면에서는 페이드가 되는데
; OBS로 캡처된 화면에는 반영이 안 되는 문제가 있었다.
; 픽셀 자체에 구워넣으면 어느 캡처 방식이든 동일하게 보인다.
if (this.WindowOpacityByte < 255)
{
this.ApplyOpacityToPixels(
bitmapBitsPointer,
windowWidth,
windowHeight
)
}
this.UpdateLayeredWindowContent(
screenDeviceContext,
memoryDeviceContext,
windowX,
windowY,
windowWidth,
windowHeight
)
}
finally
{
if (gdiPlusGraphics)
{
DllCall(
"gdiplus\GdipDeleteGraphics",
"Ptr", gdiPlusGraphics,
"Int"
)
}
if (gdiPlusBitmap)
{
DllCall(
"gdiplus\GdipDisposeImage",
"Ptr", gdiPlusBitmap,
"Int"
)
}
if (
previousBitmapHandle
&& memoryDeviceContext
)
{
DllCall(
"gdi32\SelectObject",
"Ptr", memoryDeviceContext,
"Ptr", previousBitmapHandle,
"Ptr"
)
}
if (dibBitmapHandle)
{
DllCall(
"gdi32\DeleteObject",
"Ptr", dibBitmapHandle,
"Int"
)
}
if (memoryDeviceContext)
{
DllCall(
"gdi32\DeleteDC",
"Ptr", memoryDeviceContext,
"Int"
)
}
if (screenDeviceContext)
{
DllCall(
"user32\ReleaseDC",
"Ptr", 0,
"Ptr", screenDeviceContext,
"Int"
)
}
}
}
DrawAllCommands(
gdiPlusGraphics,
canvasWidth,
canvasHeight
)
{
for drawCommandObject in this.DrawCommands
{
this.DrawTextCommand(
gdiPlusGraphics,
drawCommandObject,
canvasWidth,
canvasHeight
)
}
}
DrawTextCommand(
gdiPlusGraphics,
drawCommandObject,
canvasWidth,
canvasHeight
)
{
if (
!IsObject(drawCommandObject)
|| Type(drawCommandObject) != "TextDrawCommand"
)
{
return
}
textStyleObject :=
drawCommandObject.Style
fontHandle :=
this.CreateGdiPlusFont(
textStyleObject
)
if (!fontHandle)
return
try
{
if (
textStyleObject.GlowEnabled
&& textStyleObject.GlowSize > 0
&& textStyleObject.GlowOpacity > 0
)
{
this.DrawTextGlow(
gdiPlusGraphics,
fontHandle,
drawCommandObject,
textStyleObject,
canvasWidth,
canvasHeight
)
}
if (textStyleObject.ShadowEnabled)
{
this.DrawCommandTextLayer(
gdiPlusGraphics,
fontHandle,
drawCommandObject,
textStyleObject.ShadowOffsetX,
textStyleObject.ShadowOffsetY,
canvasWidth,
canvasHeight,
textStyleObject.ShadowColor,
textStyleObject.Opacity
)
}
if (
textStyleObject.OutlineEnabled
&& textStyleObject.OutlineSize > 0
)
{
this.DrawTextOutline(
gdiPlusGraphics,
fontHandle,
drawCommandObject,
textStyleObject,
canvasWidth,
canvasHeight
)
}
this.DrawCommandTextLayer(
gdiPlusGraphics,
fontHandle,
drawCommandObject,
0,
0,
canvasWidth,
canvasHeight,
textStyleObject.Color,
textStyleObject.Opacity
)
}
finally
{
DllCall(
"gdiplus\GdipDeleteFont",
"Ptr", fontHandle,
"Int"
)
}
}
DrawTextGlow(
gdiPlusGraphics,
fontHandle,
drawCommandObject,
textStyleObject,
canvasWidth,
canvasHeight
)
{
glowRadius :=
Ceil(
textStyleObject.GlowSize
)
effectiveGlowOpacity :=
Floor(
textStyleObject.GlowOpacity
* textStyleObject.Opacity
/ 255
)
if (
glowRadius <= 0
|| effectiveGlowOpacity <= 0
)
{
return
}
; 바깥쪽은 약하게, 글자에 가까울수록 강하게 그린다.
; 모든 레이어는 실제 ARGB Alpha로 합성된다.
Loop glowRadius
{
currentRadius :=
glowRadius
- A_Index
+ 1
layerIndex :=
glowRadius
- currentRadius
+ 1
currentOpacity :=
Max(
1,
Floor(
effectiveGlowOpacity
* layerIndex
/ glowRadius
* 0.45
)
)
Loop currentRadius * 2 + 1
{
offsetX :=
A_Index
- currentRadius
- 1
Loop currentRadius * 2 + 1
{
offsetY :=
A_Index
- currentRadius
- 1
if (
offsetX = 0
&& offsetY = 0
)
{
continue
}
; 현재 반지름의 테두리 위치만 출력한다.
if (
Abs(offsetX) != currentRadius
&& Abs(offsetY) != currentRadius
)
{
continue
}
this.DrawCommandTextLayer(
gdiPlusGraphics,
fontHandle,
drawCommandObject,
offsetX,
offsetY,
canvasWidth,
canvasHeight,
textStyleObject.GlowColor,
currentOpacity
)
}
}
}
}
DrawTextOutline(
gdiPlusGraphics,
fontHandle,
drawCommandObject,
textStyleObject,
canvasWidth,
canvasHeight
)
{
outlineRadius :=
Ceil(
textStyleObject.OutlineSize
)
Loop outlineRadius * 2 + 1
{
offsetX :=
A_Index
- outlineRadius
- 1
Loop outlineRadius * 2 + 1
{
offsetY :=
A_Index
- outlineRadius
- 1
if (
offsetX = 0
&& offsetY = 0
)
{
continue
}
if (
Max(
Abs(offsetX),
Abs(offsetY)
) > outlineRadius
)
{
continue
}
this.DrawCommandTextLayer(
gdiPlusGraphics,
fontHandle,
drawCommandObject,
offsetX,
offsetY,
canvasWidth,
canvasHeight,
textStyleObject.OutlineColor,
textStyleObject.Opacity
)
}
}
}
DrawCommandTextLayer(
gdiPlusGraphics,
fontHandle,
drawCommandObject,
offsetX,
offsetY,
canvasWidth,
canvasHeight,
rgbColor,
opacity
)
{
textLines :=
drawCommandObject.GetLines()
lineAdvance :=
drawCommandObject.GetLineAdvance()
for lineIndex, lineText in textLines
{
lineY :=
drawCommandObject.Y
+ offsetY
+ (
lineIndex - 1
) * lineAdvance
this.DrawTextLayer(
gdiPlusGraphics,
fontHandle,
lineText,
drawCommandObject.X + offsetX,
lineY,
canvasWidth,
canvasHeight,
rgbColor,
opacity
)
}
}
DrawTextLayer(
gdiPlusGraphics,
fontHandle,
text,
x,
y,
canvasWidth,
canvasHeight,
rgbColor,
opacity
)
{
brushHandle := 0
try
{
argbColor :=
this.CreateArgbColor(
rgbColor,
opacity
)
brushStatus := DllCall(
"gdiplus\GdipCreateSolidFill",
"UInt", argbColor,
"Ptr*", &brushHandle,
"Int"
)
this.ThrowIfGdiPlusFailed(
brushStatus,
"텍스트 Brush 생성"
)
layoutRectangle :=
this.CreateLayoutRectangle(
x,
y,
canvasWidth,
canvasHeight
)
drawStatus := DllCall(
"gdiplus\GdipDrawString",
"Ptr", gdiPlusGraphics,
"WStr", text,
"Int", -1,
"Ptr", fontHandle,
"Ptr", layoutRectangle,
"Ptr", 0,
"Ptr", brushHandle,
"Int"
)
this.ThrowIfGdiPlusFailed(
drawStatus,
"GDI+ 텍스트 출력"
)
}
finally
{
if (brushHandle)
{
DllCall(
"gdiplus\GdipDeleteBrush",
"Ptr", brushHandle,
"Int"
)
}
}
}
CreateGdiPlusFont(textStyleObject)
{
fontFamilyHandle := 0
fontHandle := 0
; 2026-07 추가: 먼저 외부(Assets\Fonts) 폰트 모음에서
; 이름을 찾아본다. 여기서 못 찾으면 아래에서
; 시스템 설치 폰트 → 기본 폰트 순으로 대체된다.
if (this.PrivateFontCollection)
{
DllCall(
"gdiplus\GdipCreateFontFamilyFromName",
"WStr", textStyleObject.FontName,
"Ptr", this.PrivateFontCollection,
"Ptr*", &fontFamilyHandle,
"Int"
)
}
if (!fontFamilyHandle)
{
familyStatus := DllCall(
"gdiplus\GdipCreateFontFamilyFromName",
"WStr", textStyleObject.FontName,
"Ptr", 0,
"Ptr*", &fontFamilyHandle,
"Int"
)
if (familyStatus != 0)
{
fontFamilyHandle := 0
genericStatus := DllCall(
"gdiplus\GdipGetGenericFontFamilySansSerif",
"Ptr*", &fontFamilyHandle,
"Int"
)
this.ThrowIfGdiPlusFailed(
genericStatus,
"기본 Sans Serif FontFamily 생성"
)
}
}
try
{
fontStyle :=
textStyleObject.FontWeight >= 600
? 1
: 0
fontPixelSize :=
textStyleObject.FontSize
* A_ScreenDPI
/ 72.0
createFontStatus := DllCall(
"gdiplus\GdipCreateFont",
"Ptr", fontFamilyHandle,
"Float", fontPixelSize,
"Int", fontStyle,
"Int", 2,
"Ptr*", &fontHandle,
"Int"
)
if (
createFontStatus != 0
&& fontStyle != 0
)
{
createFontStatus := DllCall(
"gdiplus\GdipCreateFont",
"Ptr", fontFamilyHandle,
"Float", fontPixelSize,
"Int", 0,
"Int", 2,
"Ptr*", &fontHandle,
"Int"
)
}
this.ThrowIfGdiPlusFailed(
createFontStatus,
"GDI+ Font 생성"
)
return fontHandle
}
finally
{
if (fontFamilyHandle)
{
DllCall(
"gdiplus\GdipDeleteFontFamily",
"Ptr", fontFamilyHandle,
"Int"
)
}
}
}
ConfigureGraphics(gdiPlusGraphics)
{
DllCall(
"gdiplus\GdipSetCompositingMode",
"Ptr", gdiPlusGraphics,
"Int", 0,
"Int"
)
DllCall(
"gdiplus\GdipSetCompositingQuality",
"Ptr", gdiPlusGraphics,
"Int", 2,
"Int"
)
DllCall(
"gdiplus\GdipSetSmoothingMode",
"Ptr", gdiPlusGraphics,
"Int", 4,
"Int"
)
DllCall(
"gdiplus\GdipSetTextRenderingHint",
"Ptr", gdiPlusGraphics,
"Int", 4,
"Int"
)
}
UpdateLayeredWindowContent(
screenDeviceContext,
memoryDeviceContext,
windowX,
windowY,
windowWidth,
windowHeight
)
{
destinationPoint :=
Buffer(8, 0)
NumPut(
"Int",
windowX,
destinationPoint,
0
)
NumPut(
"Int",
windowY,
destinationPoint,
4
)
windowSize :=
Buffer(8, 0)
NumPut(
"Int",
windowWidth,
windowSize,
0
)
NumPut(
"Int",
windowHeight,
windowSize,
4
)
sourcePoint :=
Buffer(8, 0)
blendFunction :=
Buffer(4, 0)
NumPut(
"UChar",
0,
blendFunction,
0
)
NumPut(
"UChar",
0,
blendFunction,
1
)
; 2026-07 수정: 페이드는 이제 픽셀 데이터 자체에
; 구워넣으므로(ApplyOpacityToPixels), 여기는 항상
; 완전 불투명(255)으로 고정한다. 이걸 값에 따라
; 바꾸면 모니터 화면에서 이중으로 더 빨리 페이드되어 보인다.
NumPut(
"UChar",
255,
blendFunction,
2
)
NumPut(
"UChar",
1,
blendFunction,
3
)
updateSucceeded := DllCall(
"user32\UpdateLayeredWindow",
"Ptr", this.TargetWindowHandle,
"Ptr", screenDeviceContext,
"Ptr", destinationPoint,
"Ptr", windowSize,
"Ptr", memoryDeviceContext,
"Ptr", sourcePoint,
"UInt", 0,
"Ptr", blendFunction,
"UInt", 0x00000002,
"Int"
)
if (!updateSucceeded)
{
windowsError :=
A_LastError
throw OSError(
windowsError,
"UpdateLayeredWindow 실패"
)
}
}
CreateBitmapInformation(
width,
height
)
{
bitmapInformation :=
Buffer(40, 0)
NumPut(
"UInt",
40,
bitmapInformation,
0
)
NumPut(
"Int",
width,
bitmapInformation,
4
)
NumPut(
"Int",
-height,
bitmapInformation,
8
)
NumPut(
"UShort",
1,
bitmapInformation,
12
)
NumPut(
"UShort",
32,
bitmapInformation,
14
)
NumPut(
"UInt",
0,
bitmapInformation,
16
)
return bitmapInformation
}
CreateLayoutRectangle(
x,
y,
canvasWidth,
canvasHeight
)
{
layoutRectangle :=
Buffer(16, 0)
rectangleWidth :=
Max(
1,
canvasWidth - x
)
rectangleHeight :=
Max(
1,
canvasHeight - y
)
NumPut(
"Float",
x,
layoutRectangle,
0
)
NumPut(
"Float",
y,
layoutRectangle,
4
)
NumPut(
"Float",
rectangleWidth,
layoutRectangle,
8
)
NumPut(
"Float",
rectangleHeight,
layoutRectangle,
12
)
return layoutRectangle
}
CreateArgbColor(
rgbColor,
opacity
)
{
normalizedColor :=
this.NormalizeColor(
rgbColor
)
normalizedOpacity :=
Max(
0,
Min(
255,
Floor(opacity)
)
)
rgbValue :=
("0x" normalizedColor) + 0
return (
(normalizedOpacity << 24)
| rgbValue
)
}
NormalizeColor(colorValue)
{
normalizedColor :=
StrUpper(
Trim(colorValue)
)
if (SubStr(normalizedColor, 1, 1) = "#")
{
normalizedColor :=
SubStr(
normalizedColor,
2
)
}
if (!RegExMatch(
normalizedColor,
"^[0-9A-F]{6}$"
))
{
throw ValueError(
"색상은 RRGGBB 형식이어야 합니다.",
-1,
colorValue
)
}
return normalizedColor
}
GetWindowRectangle()
{
if (this.BoundsOverrideEnabled)
{
return Map(
"X", this.OverrideX,
"Y", this.OverrideY,
"Width", this.OverrideWidth,
"Height", this.OverrideHeight
)
}
rectangleBuffer :=
Buffer(16, 0)
getRectangleSucceeded := DllCall(
"user32\GetWindowRect",
"Ptr", this.TargetWindowHandle,
"Ptr", rectangleBuffer,
"Int"
)
if (!getRectangleSucceeded)
{
throw OSError(
A_LastError,
"Overlay 창 위치 확인 실패"
)
}
left :=
NumGet(
rectangleBuffer,
0,
"Int"
)
top :=
NumGet(
rectangleBuffer,
4,
"Int"
)
right :=
NumGet(
rectangleBuffer,
8,
"Int"
)
bottom :=
NumGet(
rectangleBuffer,
12,
"Int"
)
return Map(
"X", left,
"Y", top,
"Width", right - left,
"Height", bottom - top
)
}
StartGdiPlus()
{
if (this.GdiPlusStarted)
return
startupInputSize :=
A_PtrSize = 8
? 24
: 16
startupInput :=
Buffer(
startupInputSize,
0
)
callbackOffset :=
A_PtrSize = 8
? 8
: 4
suppressThreadOffset :=
A_PtrSize = 8
? 16
: 8
suppressCodecsOffset :=
A_PtrSize = 8
? 20
: 12
NumPut(
"UInt",
1,
startupInput,
0
)
NumPut(
"Ptr",
0,
startupInput,
callbackOffset
)
NumPut(
"Int",
false,
startupInput,
suppressThreadOffset
)
NumPut(
"Int",
false,
startupInput,
suppressCodecsOffset
)
; AutoHotkey v2에서는 출력 변수 주소를 넘기기 전에
; 로컬 변수를 먼저 초기화해야 한다.
gdiPlusToken := 0
startupStatus := DllCall(
"gdiplus\GdiplusStartup",
"UPtr*", &gdiPlusToken,
"Ptr", startupInput,
"Ptr", 0,
"Int"
)
this.ThrowIfGdiPlusFailed(
startupStatus,
"GDI+ 시작"
)
this.GdiPlusToken :=
gdiPlusToken
this.GdiPlusStarted :=
true
}
; 2026-07 추가:
; Assets\Fonts 폴더의 .ttf/.otf/.ttc 파일들을
; Windows에 설치하지 않고도 쓸 수 있도록 GDI+ 전용
; 폰트 모음(Private Font Collection)에 불러온다.
;
; 실패해도(폴더가 없거나, 특정 파일이 깨졌거나) 프로그램은
; 계속 실행되어야 하므로 여기서는 예외를 던지지 않는다.
; 이후 CreateGdiPlusFont()에서 이름을 못 찾으면
; 시스템 설치 폰트, 그다음 기본 폰트 순으로 자동 대체된다.
LoadPrivateFonts(fontsFolderPath)
{
if (
Trim(fontsFolderPath) = ""
|| !DirExist(fontsFolderPath)
)
{
return
}
collectionHandle := 0
try
{
collectionStatus := DllCall(
"gdiplus\GdipNewPrivateFontCollection",
"Ptr*", &collectionHandle,
"Int"
)
if (collectionStatus != 0)
{
return
}
Loop Files, fontsFolderPath . "\*.ttf"
{
this.AddFontFileToCollection(
collectionHandle,
A_LoopFileFullPath
)
}
Loop Files, fontsFolderPath . "\*.otf"
{
this.AddFontFileToCollection(
collectionHandle,
A_LoopFileFullPath
)
}
Loop Files, fontsFolderPath . "\*.ttc"
{
this.AddFontFileToCollection(
collectionHandle,
A_LoopFileFullPath
)
}
this.PrivateFontCollection :=
collectionHandle
}
catch
{
; 폰트 폴더를 못 읽어도 프로그램은 계속 돌아가야 한다.
; (시스템 폰트만으로 대체)
}
}
AddFontFileToCollection(
collectionHandle,
fontFilePath
)
{
try
{
DllCall(
"gdiplus\GdipPrivateAddFontFile",
"Ptr", collectionHandle,
"WStr", fontFilePath,
"Int"
)
}
catch
{
; 파일 하나가 깨져 있어도 나머지 폰트는 계속 불러온다.
}
}
ThrowIfGdiPlusFailed(
statusCode,
operationName
)
{
if (statusCode != 0)
{
throw Error(
operationName
. " 실패. GDI+ 상태 코드: "
. statusCode
)
}
}
}
🔴 Engine/Rendering/OverlayRenderer.ahk — GDI 직접 그리기 렌더러 (Build 1.7 방식, 테스트에서 사용) #
;============================================================
; OverlayRenderer
;
; TextDrawCommand 배열을 Windows GDI로 직접 그린다.
;
; Build 1.7 렌더링 순서:
;
; 1. Background
; 2. Shadow
; 3. Outline
; 4. Main Text
;
; 메시지 판단, 스타일 결정, 위치 계산은 담당하지 않는다.
;============================================================
#Include TextDrawCommand.ahk
class OverlayRenderer
{
;--------------------------------------------------------
; __New()
; 렌더링 대상 창과 테스트용 캔버스 색상 설정
;--------------------------------------------------------
__New(
targetWindowHandle,
canvasColor := "000000"
)
{
this.ValidateWindowHandle(
targetWindowHandle
)
this.ValidateColorString(
canvasColor,
"캔버스 배경색"
)
this.TargetWindowHandle :=
targetWindowHandle
this.CanvasColor :=
StrUpper(canvasColor)
this.DrawCommands := []
this.PaintMessageHandler :=
ObjBindMethod(
this,
"HandlePaintMessage"
)
this.EraseBackgroundHandler :=
ObjBindMethod(
this,
"HandleEraseBackground"
)
; WM_PAINT
OnMessage(
0x000F,
this.PaintMessageHandler
)
; WM_ERASEBKGND
OnMessage(
0x0014,
this.EraseBackgroundHandler
)
}
;--------------------------------------------------------
; SetDrawCommands()
; 그릴 TextDrawCommand 배열의 복사본 보관
;--------------------------------------------------------
SetDrawCommands(drawCommandObjects)
{
this.ValidateDrawCommands(
drawCommandObjects
)
this.DrawCommands :=
drawCommandObjects.Clone()
this.RequestRedraw()
}
;--------------------------------------------------------
; SetCanvasColor()
; 테스트용 캔버스 배경색 변경
;--------------------------------------------------------
SetCanvasColor(canvasColor)
{
this.ValidateColorString(
canvasColor,
"캔버스 배경색"
)
this.CanvasColor :=
StrUpper(canvasColor)
this.RequestRedraw()
}
;--------------------------------------------------------
; Clear()
; 현재 렌더링 명령 제거
;--------------------------------------------------------
Clear()
{
this.DrawCommands := []
this.RequestRedraw()
}
;--------------------------------------------------------
; GetDrawCommandCount()
; 현재 명령 개수 반환
;--------------------------------------------------------
GetDrawCommandCount()
{
return this.DrawCommands.Length
}
;--------------------------------------------------------
; RequestRedraw()
; Windows에 다시 그리기 요청
;--------------------------------------------------------
RequestRedraw()
{
DllCall(
"user32\InvalidateRect",
"Ptr", this.TargetWindowHandle,
"Ptr", 0,
"Int", true
)
}
;--------------------------------------------------------
; HandlePaintMessage()
; 대상 창의 WM_PAINT만 처리
;--------------------------------------------------------
HandlePaintMessage(
wParam,
lParam,
messageNumber,
windowHandle
)
{
if (
windowHandle
!= this.TargetWindowHandle
)
{
return
}
this.Render()
return 0
}
;--------------------------------------------------------
; HandleEraseBackground()
; Windows 기본 배경 지우기 방지
;--------------------------------------------------------
HandleEraseBackground(
wParam,
lParam,
messageNumber,
windowHandle
)
{
if (
windowHandle
!= this.TargetWindowHandle
)
{
return
}
return 1
}
;--------------------------------------------------------
; Render()
; 한 번의 Paint 사이클에서 전체 렌더링
;--------------------------------------------------------
Render()
{
paintStructureSize :=
(A_PtrSize = 8) ? 72 : 64
paintStructure :=
Buffer(
paintStructureSize,
0
)
deviceContext := DllCall(
"user32\BeginPaint",
"Ptr", this.TargetWindowHandle,
"Ptr", paintStructure,
"Ptr"
)
if (!deviceContext)
return
try
{
this.DrawCanvas(
deviceContext
)
for drawCommandObject in this.DrawCommands
{
this.DrawTextCommand(
deviceContext,
drawCommandObject
)
}
}
finally
{
DllCall(
"user32\EndPaint",
"Ptr", this.TargetWindowHandle,
"Ptr", paintStructure
)
}
}
;--------------------------------------------------------
; DrawCanvas()
; 창 전체를 캔버스 색상으로 채움
;--------------------------------------------------------
DrawCanvas(deviceContext)
{
clientRectangle :=
Buffer(16, 0)
DllCall(
"user32\GetClientRect",
"Ptr", this.TargetWindowHandle,
"Ptr", clientRectangle
)
canvasColorReference :=
this.ConvertRgbToColorReference(
this.CanvasColor
)
canvasBrush := DllCall(
"gdi32\CreateSolidBrush",
"UInt", canvasColorReference,
"Ptr"
)
if (!canvasBrush)
return
try
{
DllCall(
"user32\FillRect",
"Ptr", deviceContext,
"Ptr", clientRectangle,
"Ptr", canvasBrush
)
}
finally
{
DllCall(
"gdi32\DeleteObject",
"Ptr", canvasBrush
)
}
}
;--------------------------------------------------------
; DrawTextCommand()
;
; 렌더링 순서:
;
; 1. Background
; 2. Shadow
; 3. Outline
; 4. Main Text
;--------------------------------------------------------
DrawTextCommand(
deviceContext,
drawCommandObject
)
{
textStyleObject :=
drawCommandObject.Style
fontHandle :=
this.CreateFont(
textStyleObject
)
if (!fontHandle)
return
previousFontHandle := DllCall(
"gdi32\SelectObject",
"Ptr", deviceContext,
"Ptr", fontHandle,
"Ptr"
)
try
{
DllCall(
"gdi32\SetBkMode",
"Ptr", deviceContext,
"Int", 1
)
;------------------------------------------------
; 2. 그림자
;------------------------------------------------
if (textStyleObject.ShadowEnabled)
{
this.DrawTextLayer(
deviceContext,
drawCommandObject.Text,
drawCommandObject.X
+ textStyleObject.ShadowOffsetX,
drawCommandObject.Y
+ textStyleObject.ShadowOffsetY,
textStyleObject.ShadowColor
)
}
;------------------------------------------------
; 3. 외곽선
;------------------------------------------------
if (
textStyleObject.OutlineEnabled
&& textStyleObject.OutlineSize > 0
)
{
this.DrawOutlineLayers(
deviceContext,
drawCommandObject.Text,
drawCommandObject.X,
drawCommandObject.Y,
textStyleObject.OutlineColor,
textStyleObject.OutlineSize
)
}
;------------------------------------------------
; 4. 본문
;------------------------------------------------
this.DrawTextLayer(
deviceContext,
drawCommandObject.Text,
drawCommandObject.X,
drawCommandObject.Y,
textStyleObject.Color
)
}
finally
{
if (previousFontHandle)
{
DllCall(
"gdi32\SelectObject",
"Ptr", deviceContext,
"Ptr", previousFontHandle
)
}
DllCall(
"gdi32\DeleteObject",
"Ptr", fontHandle
)
}
}
;--------------------------------------------------------
; MeasureText()
; 현재 선택된 GDI 글꼴로 단일 텍스트 크기를 측정
;
; 반환:
; Map("Width", 폭, "Height", 높이)
;--------------------------------------------------------
MeasureText(deviceContext, text)
{
sizeBuffer :=
Buffer(8, 0)
measurementSucceeded := DllCall(
"gdi32\GetTextExtentPoint32W",
"Ptr", deviceContext,
"Str", text,
"Int", StrLen(text),
"Ptr", sizeBuffer,
"Int"
)
if (!measurementSucceeded)
{
return Map(
"Width", 0,
"Height", 0
)
}
return Map(
"Width",
NumGet(
sizeBuffer,
0,
"Int"
),
"Height",
NumGet(
sizeBuffer,
4,
"Int"
)
)
}
;--------------------------------------------------------
; DrawOutlineLayers()
; 본문 주변에 외곽선색 텍스트를 여러 번 출력
;--------------------------------------------------------
DrawOutlineLayers(
deviceContext,
text,
x,
y,
outlineColor,
outlineSize
)
{
outlineRadius :=
Ceil(outlineSize)
if (outlineRadius <= 0)
return
offsetX := -outlineRadius
while (offsetX <= outlineRadius)
{
offsetY := -outlineRadius
while (offsetY <= outlineRadius)
{
if (
offsetX != 0
|| offsetY != 0
)
{
this.DrawTextLayer(
deviceContext,
text,
x + offsetX,
y + offsetY,
outlineColor
)
}
offsetY += 1
}
offsetX += 1
}
}
;--------------------------------------------------------
; DrawTextLayer()
; 그림자, 외곽선, 본문 텍스트 한 겹 출력
;--------------------------------------------------------
DrawTextLayer(
deviceContext,
text,
x,
y,
rgbColor
)
{
colorReference :=
this.ConvertRgbToColorReference(
rgbColor
)
DllCall(
"gdi32\SetTextColor",
"Ptr", deviceContext,
"UInt", colorReference
)
textRectangle :=
this.CreateTextRectangle(
x,
y
)
drawTextOptions :=
0x00000010 ; DT_WORDBREAK
| 0x00000100 ; DT_NOCLIP
| 0x00000800 ; DT_NOPREFIX
DllCall(
"user32\DrawTextW",
"Ptr", deviceContext,
"Str", text,
"Int", -1,
"Ptr", textRectangle,
"UInt", drawTextOptions
)
}
;--------------------------------------------------------
; CreateFont()
; TextStyle 정보로 GDI 글꼴 생성
;--------------------------------------------------------
CreateFont(textStyleObject)
{
fontHeight := -DllCall(
"kernel32\MulDiv",
"Int", textStyleObject.FontSize,
"Int", A_ScreenDPI,
"Int", 72,
"Int"
)
return DllCall(
"gdi32\CreateFontW",
"Int", fontHeight,
"Int", 0,
"Int", 0,
"Int", 0,
"Int", textStyleObject.FontWeight,
"UInt", 0,
"UInt", 0,
"UInt", 0,
"UInt", 1,
"UInt", 0,
"UInt", 0,
"UInt", 5,
"UInt", 0,
"Str", textStyleObject.FontName,
"Ptr"
)
}
;--------------------------------------------------------
; CreateTextRectangle()
; 텍스트 출력용 RECT 생성
;--------------------------------------------------------
CreateTextRectangle(x, y)
{
clientRectangle :=
Buffer(16, 0)
DllCall(
"user32\GetClientRect",
"Ptr", this.TargetWindowHandle,
"Ptr", clientRectangle
)
right :=
NumGet(
clientRectangle,
8,
"Int"
)
bottom :=
NumGet(
clientRectangle,
12,
"Int"
)
return this.CreateRectangle(
x,
y,
right,
bottom
)
}
;--------------------------------------------------------
; CreateRectangle()
; Windows RECT 구조체 생성
;--------------------------------------------------------
CreateRectangle(
left,
top,
right,
bottom
)
{
rectangleBuffer :=
Buffer(16, 0)
NumPut(
"Int",
left,
rectangleBuffer,
0
)
NumPut(
"Int",
top,
rectangleBuffer,
4
)
NumPut(
"Int",
right,
rectangleBuffer,
8
)
NumPut(
"Int",
bottom,
rectangleBuffer,
12
)
return rectangleBuffer
}
;--------------------------------------------------------
; ConvertRgbToColorReference()
; RRGGBB를 Windows COLORREF로 변환
;--------------------------------------------------------
ConvertRgbToColorReference(rgbColor)
{
rgbValue :=
("0x" rgbColor) + 0
red :=
(rgbValue >> 16) & 0xFF
green :=
(rgbValue >> 8) & 0xFF
blue :=
rgbValue & 0xFF
return (
red
| (green << 8)
| (blue << 16)
)
}
;--------------------------------------------------------
; ValidateWindowHandle()
; 유효한 Windows 창 핸들 검사
;--------------------------------------------------------
ValidateWindowHandle(windowHandle)
{
if (!IsNumber(windowHandle))
{
throw TypeError(
"OverlayRenderer에는 숫자 HWND가 필요합니다."
)
}
isValidWindow := DllCall(
"user32\IsWindow",
"Ptr", windowHandle,
"Int"
)
if (!isValidWindow)
{
throw ValueError(
"유효하지 않은 창 핸들입니다.",
-1,
windowHandle
)
}
}
;--------------------------------------------------------
; ValidateColorString()
; RRGGBB 색상 문자열 검사
;--------------------------------------------------------
ValidateColorString(
rgbColor,
displayName
)
{
if (
Type(rgbColor) != "String"
|| !RegExMatch(
rgbColor,
"i)^[0-9A-F]{6}$"
)
)
{
throw ValueError(
displayName
. "은 RRGGBB 형식이어야 합니다.",
-1,
rgbColor
)
}
}
;--------------------------------------------------------
; ValidateDrawCommands()
; TextDrawCommand 배열인지 검사
;--------------------------------------------------------
ValidateDrawCommands(drawCommandObjects)
{
if (
Type(drawCommandObjects)
!= "Array"
)
{
throw TypeError(
"OverlayRenderer에는 TextDrawCommand 배열이 필요합니다."
)
}
for drawCommandObject in drawCommandObjects
{
if (
!IsObject(drawCommandObject)
|| Type(drawCommandObject)
!= "TextDrawCommand"
)
{
throw TypeError(
"배열의 모든 항목은 TextDrawCommand 객체여야 합니다."
)
}
}
}
}
🔵 Engine · Services — 저장소와 컨트롤러 #
🔵 Engine/Services/MessagePipeline.ahk — 버퍼 → 스타일 → 측정 → 그리기 명령 변환 #
;============================================================
; MessagePipeline
;
; MessageBuffer의 메시지를 스타일 결정과 실제 GDI 측정을
; 거쳐 TextDrawCommand 배열로 변환한다.
;
; Build 1.7.2:
; 측정 완료된 DrawCommand의 실제 높이를 기준으로
; 다음 메시지 위치를 계산한다.
;============================================================
#Include ..\Model\ChatMessage.ahk
#Include ..\Model\MessageBuffer.ahk
#Include ..\Pipeline\StyleResolver.ahk
#Include ..\Pipeline\TextLayout.ahk
#Include ..\Rendering\TextDrawCommand.ahk
class MessagePipeline
{
;--------------------------------------------------------
; __New()
;--------------------------------------------------------
__New(
messageBufferObject,
styleResolverObject,
textLayoutObject
)
{
this.ValidateMessageBuffer(
messageBufferObject
)
this.ValidateStyleResolver(
styleResolverObject
)
this.ValidateTextLayout(
textLayoutObject
)
this.MessageBuffer :=
messageBufferObject
this.StyleResolver :=
styleResolverObject
this.TextLayout :=
textLayoutObject
}
;--------------------------------------------------------
; BuildDrawCommands()
;--------------------------------------------------------
BuildDrawCommands()
{
pipelineChatMessages :=
this.MessageBuffer.GetMessages()
pipelineDrawCommands := []
currentVisualTop :=
this.TextLayout.TopMargin
for pipelineChatMessage
in pipelineChatMessages
{
pipelineTextStyle :=
this.StyleResolver.Resolve(
pipelineChatMessage
)
pipelineDrawCommand :=
this.TextLayout.BuildCommandAtVisualTop(
pipelineChatMessage,
pipelineTextStyle,
currentVisualTop
)
pipelineDrawCommands.Push(
pipelineDrawCommand
)
currentVisualTop +=
this.TextLayout.CalculateLineAdvance(
pipelineDrawCommand
)
}
return pipelineDrawCommands
}
;--------------------------------------------------------
; GetMessageCount()
;--------------------------------------------------------
GetMessageCount()
{
return this.MessageBuffer.GetCount()
}
;--------------------------------------------------------
; ValidateMessageBuffer()
;--------------------------------------------------------
ValidateMessageBuffer(messageBufferObject)
{
if (
!IsObject(messageBufferObject)
|| Type(messageBufferObject)
!= "MessageBuffer"
)
{
throw TypeError(
"MessagePipeline에는 MessageBuffer 객체가 필요합니다."
)
}
}
;--------------------------------------------------------
; ValidateStyleResolver()
;--------------------------------------------------------
ValidateStyleResolver(styleResolverObject)
{
if (
!IsObject(styleResolverObject)
|| Type(styleResolverObject)
!= "StyleResolver"
)
{
throw TypeError(
"MessagePipeline에는 StyleResolver 객체가 필요합니다."
)
}
}
;--------------------------------------------------------
; ValidateTextLayout()
;--------------------------------------------------------
ValidateTextLayout(textLayoutObject)
{
if (
!IsObject(textLayoutObject)
|| Type(textLayoutObject)
!= "TextLayout"
)
{
throw TypeError(
"MessagePipeline에는 TextLayout 객체가 필요합니다."
)
}
}
}
🔵 Engine/Services/LiveOverlayController.ahk — 메시지 추가 → 배치 → 렌더 갱신 총괄 #
;============================================================
; LiveOverlayController
;
; Build 2.6.2
;
; 역할:
; - 실시간 메시지 추가
; - MessageBuffer / MessagePipeline 관리
; - Viewport 적용
; - LayeredOverlayRenderer 갱신
; - 전체 메시지 비우기
;============================================================
#Include MessagePipeline.ahk
#Include ..\Pipeline\ViewportLayout.ahk
class LiveOverlayController
{
__New(
styleResolverObject,
textLayoutObject,
viewportLayoutObject,
rendererObject,
bufferCapacity := 100,
visibleMessageLimit := 0
)
{
this.ValidateRequiredObject(
styleResolverObject,
"StyleResolver"
)
this.ValidateRequiredObject(
textLayoutObject,
"TextLayout"
)
this.ValidateRequiredObject(
viewportLayoutObject,
"ViewportLayout"
)
this.ValidateRequiredObject(
rendererObject,
"LayeredOverlayRenderer"
)
this.ValidatePositiveInteger(
bufferCapacity,
"메시지 버퍼 크기"
)
if (visibleMessageLimit = 0)
{
visibleMessageLimit :=
bufferCapacity
}
this.ValidatePositiveInteger(
visibleMessageLimit,
"화면 표시 메시지 개수"
)
if (
visibleMessageLimit
> bufferCapacity
)
{
throw ValueError(
"화면 표시 메시지 개수는 버퍼 크기보다 클 수 없습니다.",
-1,
visibleMessageLimit
)
}
this.StyleResolver :=
styleResolverObject
this.TextLayout :=
textLayoutObject
this.ViewportLayoutManager :=
viewportLayoutObject
this.Renderer :=
rendererObject
this.BufferCapacity :=
bufferCapacity
this.VisibleMessageLimit :=
visibleMessageLimit
this.MessageCount := 0
this.CreatePipeline()
}
AddMessage(
author,
messageText,
messageType := "Normal"
)
{
this.ValidateNonEmptyString(
author,
"작성자"
)
this.ValidateNonEmptyString(
messageText,
"메시지"
)
this.ValidateNonEmptyString(
messageType,
"메시지 종류"
)
chatMessageObject :=
ChatMessage(
Trim(author),
Trim(messageText),
Trim(messageType)
)
this.MessageBuffer.Add(
chatMessageObject
)
this.MessageCount += 1
return this.Refresh()
}
ClearMessages()
{
this.MessageCount := 0
this.CreatePipeline()
this.Renderer.SetDrawCommands(
[]
)
}
Refresh()
{
allDrawCommands :=
this.MessagePipeline.BuildDrawCommands()
limitedDrawCommands :=
this.TakeLatestDrawCommands(
allDrawCommands,
this.VisibleMessageLimit
)
visibleDrawCommands :=
this.ViewportLayoutManager.Apply(
limitedDrawCommands
)
this.Renderer.SetDrawCommands(
visibleDrawCommands
)
return visibleDrawCommands.Length
}
SetVisibleMessageLimit(
visibleMessageLimit
)
{
this.ValidatePositiveInteger(
visibleMessageLimit,
"화면 표시 메시지 개수"
)
if (
visibleMessageLimit
> this.BufferCapacity
)
{
throw ValueError(
"화면 표시 메시지 개수는 버퍼 크기보다 클 수 없습니다.",
-1,
visibleMessageLimit
)
}
this.VisibleMessageLimit :=
visibleMessageLimit
return this.Refresh()
}
GetVisibleMessageLimit()
{
return this.VisibleMessageLimit
}
TakeLatestDrawCommands(
drawCommands,
visibleMessageLimit
)
{
if (Type(drawCommands) != "Array")
{
throw TypeError(
"TextDrawCommand 배열이 필요합니다."
)
}
limitedCommands := []
if (drawCommands.Length = 0)
{
return limitedCommands
}
firstIndex :=
Max(
1,
drawCommands.Length
- visibleMessageLimit
+ 1
)
Loop (
drawCommands.Length
- firstIndex
+ 1
)
{
sourceIndex :=
firstIndex
+ A_Index
- 1
limitedCommands.Push(
drawCommands[
sourceIndex
]
)
}
return limitedCommands
}
CreatePipeline()
{
this.MessageBuffer :=
MessageBuffer(
this.BufferCapacity
)
this.MessagePipeline :=
MessagePipeline(
this.MessageBuffer,
this.StyleResolver,
this.TextLayout
)
}
ValidateRequiredObject(
objectValue,
objectName
)
{
if (!IsObject(objectValue))
{
throw TypeError(
objectName
. " 객체가 필요합니다."
)
}
}
ValidateNonEmptyString(
value,
valueName
)
{
if (Type(value) != "String")
{
throw TypeError(
valueName
. "은 문자열이어야 합니다."
)
}
if (Trim(value) = "")
{
throw ValueError(
valueName
. "은 비어 있을 수 없습니다."
)
}
}
ValidatePositiveInteger(
value,
valueName
)
{
if (!IsNumber(value))
{
throw TypeError(
valueName
. "은 숫자여야 합니다."
)
}
if (
value <= 0
|| Floor(value) != value
)
{
throw ValueError(
valueName
. "은 1 이상의 정수여야 합니다.",
-1,
value
)
}
}
}
🔵 Engine/Services/ChatMacroRepository.ahk — 채팅 매크로 저장소 (TSV) #
;============================================================
; ChatMacroRepository
;
; Build 2.4.5
;
; UTF-8 TSV 파일에 채팅 매크로를 저장한다.
;
; 필드:
; ID / 카테고리 / 이름 / 작성자 / 메시지 종류 / 메시지
;
; 탭, 줄바꿈, 역슬래시는 저장 전에 이스케이프한다.
;============================================================
#Include ..\Model\ChatMacro.ahk
class ChatMacroRepository
{
__New(storagePath)
{
if (Type(storagePath) != "String")
{
throw TypeError(
"매크로 저장 경로는 문자열이어야 합니다."
)
}
if (Trim(storagePath) = "")
{
throw ValueError(
"매크로 저장 경로가 비어 있습니다."
)
}
this.StoragePath := storagePath
this.EnsureStorageFile()
}
LoadAll()
{
macroObjects := []
if (!FileExist(this.StoragePath))
{
return macroObjects
}
fileText :=
FileRead(
this.StoragePath,
"UTF-8"
)
Loop Parse fileText, "`n", "`r"
{
lineText :=
A_LoopField
if (Trim(lineText) = "")
{
continue
}
if (
SubStr(
LTrim(lineText),
1,
1
)
= "#"
)
{
continue
}
fieldParts :=
StrSplit(
lineText,
"`t"
)
if (fieldParts.Length != 6)
{
continue
}
try
{
macroObjects.Push(
ChatMacro(
this.UnescapeField(
fieldParts[1]
),
this.UnescapeField(
fieldParts[2]
),
this.UnescapeField(
fieldParts[3]
),
this.UnescapeField(
fieldParts[4]
),
this.UnescapeField(
fieldParts[5]
),
this.UnescapeField(
fieldParts[6]
)
)
)
}
}
return macroObjects
}
AddMacro(chatMacroObject)
{
this.ValidateChatMacro(
chatMacroObject
)
macroObjects :=
this.LoadAll()
macroObjects.Push(
chatMacroObject
)
this.SaveAll(
macroObjects
)
return chatMacroObject
}
DeleteById(macroId)
{
if (Type(macroId) != "String")
{
throw TypeError(
"삭제할 매크로 ID는 문자열이어야 합니다."
)
}
sourceMacros :=
this.LoadAll()
remainingMacros := []
deleted := false
for chatMacroObject in sourceMacros
{
if (
chatMacroObject.Id
= macroId
)
{
deleted := true
continue
}
remainingMacros.Push(
chatMacroObject
)
}
if (deleted)
{
this.SaveAll(
remainingMacros
)
}
return deleted
}
CountByCategory(category)
{
this.ValidateCategoryName(
category
)
matchingCount := 0
for chatMacroObject in this.LoadAll()
{
if (
chatMacroObject.Category
= category
)
{
matchingCount += 1
}
}
return matchingCount
}
DeleteByCategory(category)
{
this.ValidateCategoryName(
category
)
sourceMacros :=
this.LoadAll()
remainingMacros := []
deletedCount := 0
for chatMacroObject in sourceMacros
{
if (
chatMacroObject.Category
= category
)
{
deletedCount += 1
continue
}
remainingMacros.Push(
chatMacroObject
)
}
if (deletedCount > 0)
{
this.SaveAll(
remainingMacros
)
}
return deletedCount
}
CreateId()
{
return (
FormatTime(
A_Now,
"yyyyMMddHHmmss"
)
. "_"
. A_TickCount
. "_"
. Random(
100000,
999999
)
)
}
SaveAll(macroObjects)
{
if (Type(macroObjects) != "Array")
{
throw TypeError(
"저장할 매크로 배열이 필요합니다."
)
}
outputText :=
"# OBSLiveChat Chat Macros UTF-8`n"
for chatMacroObject in macroObjects
{
this.ValidateChatMacro(
chatMacroObject
)
outputText .=
this.EscapeField(
chatMacroObject.Id
)
. "`t"
. this.EscapeField(
chatMacroObject.Category
)
. "`t"
. this.EscapeField(
chatMacroObject.Name
)
. "`t"
. this.EscapeField(
chatMacroObject.Author
)
. "`t"
. this.EscapeField(
chatMacroObject.MessageType
)
. "`t"
. this.EscapeField(
chatMacroObject.Text
)
. "`n"
}
this.EnsureStorageDirectory()
if (FileExist(this.StoragePath))
{
FileDelete(
this.StoragePath
)
}
FileAppend(
outputText,
this.StoragePath,
"UTF-8"
)
}
EnsureStorageFile()
{
this.EnsureStorageDirectory()
if (FileExist(this.StoragePath))
{
return
}
FileAppend(
"# OBSLiveChat Chat Macros UTF-8`n",
this.StoragePath,
"UTF-8"
)
}
EnsureStorageDirectory()
{
storageFileName := ""
storageDirectory := ""
SplitPath(
this.StoragePath,
&storageFileName,
&storageDirectory
)
if (
storageDirectory != ""
&& !DirExist(storageDirectory)
)
{
DirCreate(
storageDirectory
)
}
}
EscapeField(fieldValue)
{
escapedValue :=
StrReplace(
fieldValue,
"\",
"\\"
)
escapedValue :=
StrReplace(
escapedValue,
"`t",
"\t"
)
escapedValue :=
StrReplace(
escapedValue,
"`r`n",
"\n"
)
escapedValue :=
StrReplace(
escapedValue,
"`r",
"\n"
)
escapedValue :=
StrReplace(
escapedValue,
"`n",
"\n"
)
return escapedValue
}
UnescapeField(fieldValue)
{
resultText := ""
currentIndex := 1
fieldLength :=
StrLen(
fieldValue
)
while (
currentIndex
<= fieldLength
)
{
currentCharacter :=
SubStr(
fieldValue,
currentIndex,
1
)
if (
currentCharacter
!= "\"
)
{
resultText .=
currentCharacter
currentIndex += 1
continue
}
if (
currentIndex
= fieldLength
)
{
resultText .= "\"
break
}
nextCharacter :=
SubStr(
fieldValue,
currentIndex + 1,
1
)
switch nextCharacter
{
case "t":
resultText .= "`t"
case "n":
resultText .= "`n"
case "\":
resultText .= "\"
default:
resultText .=
"\"
. nextCharacter
}
currentIndex += 2
}
return resultText
}
ValidateCategoryName(category)
{
if (Type(category) != "String")
{
throw TypeError(
"카테고리 이름은 문자열이어야 합니다."
)
}
if (Trim(category) = "")
{
throw ValueError(
"카테고리 이름은 비어 있을 수 없습니다."
)
}
if (Trim(category) = "전체")
{
throw ValueError(
"'전체' 카테고리는 삭제할 수 없습니다."
)
}
}
ValidateChatMacro(
chatMacroObject
)
{
if (
!IsObject(chatMacroObject)
|| Type(chatMacroObject)
!= "ChatMacro"
)
{
throw TypeError(
"ChatMacro 객체가 필요합니다."
)
}
}
}
🔵 Engine/Services/AdminIdentityRepository.ahk — 관리자 ID 저장소 (TSV, 캐시) #
;============================================================
; AdminIdentityRepository
;
; 역할:
; - 관리자 YouTube 채널 ID와 표시 이름 저장
; - 수동/향후 YouTube 메시지의 관리자 여부 판정
; - UTF-8 TSV 사용
;
; 캐싱 관련 (2026-07 수정):
; - IsAdministrator()가 작성자 입력창의 Change 이벤트마다
; 호출되면서, 예전에는 그때마다 디스크 파일을 통째로
; 다시 읽고 파싱했다. 이게 타이핑 랙의 원인이었다.
; - 이제 LoadAll()은 메모리에 캐싱된 목록을 우선 반환하고,
; 실제로 목록이 바뀌는 시점(Add/Delete)에만
; 캐시를 무효화해서 다음 호출에서 다시 읽는다.
;============================================================
class AdminIdentityRepository
{
__New(storageFilePath)
{
if (
Type(storageFilePath) != "String"
|| Trim(storageFilePath) = ""
)
{
throw ValueError(
"관리자 ID 저장 파일 경로가 필요합니다."
)
}
this.StorageFilePath :=
storageFilePath
this.CachedIdentities := ""
this.EnsureStorageFile()
}
; 캐시를 비워서 다음 LoadAll() 호출 때
; 디스크에서 다시 읽도록 한다.
InvalidateCache()
{
this.CachedIdentities := ""
}
LoadAll()
{
; 캐시가 있으면 디스크를 건드리지 않고 바로 반환한다.
; (IsAdministrator()가 매 키 입력마다 불려도
; 실제 파일 읽기는 목록이 바뀔 때 한 번만 일어난다.)
if (IsObject(this.CachedIdentities))
{
return this.CachedIdentities
}
this.EnsureStorageFile()
fileText :=
FileRead(
this.StorageFilePath,
"UTF-8"
)
lines :=
StrSplit(
fileText,
"`n",
"`r"
)
identityObjects := []
Loop lines.Length
{
if (A_Index = 1)
{
continue
}
; 행 전체에 Trim()을 사용하면
; 앞쪽 탭이 제거되어 빈 ChannelId 열이 사라진다.
; 예: `t관리자 -> 관리자
; 줄 끝의 CR만 제거하여 빈 첫 번째 열을 보존한다.
lineText :=
RTrim(
lines[A_Index],
"`r"
)
if (
lineText = ""
|| StrReplace(
lineText,
"`t",
""
) = ""
)
{
continue
}
fields :=
StrSplit(
lineText,
"`t"
)
channelId :=
fields.Length >= 1
? Trim(fields[1])
: ""
displayName :=
fields.Length >= 2
? Trim(fields[2])
: ""
if (
channelId = ""
&& displayName = ""
)
{
continue
}
identityObjects.Push(
Map(
"ChannelId", channelId,
"DisplayName", displayName
)
)
}
this.CachedIdentities :=
identityObjects
return identityObjects
}
Add(
channelId,
displayName
)
{
normalizedChannelId :=
this.Normalize(
channelId
)
normalizedDisplayName :=
this.Normalize(
displayName
)
if (
normalizedChannelId = ""
&& normalizedDisplayName = ""
)
{
throw ValueError(
"채널 ID 또는 표시 이름 중 하나는 입력해야 합니다."
)
}
if (
this.Contains(
normalizedChannelId,
normalizedDisplayName
)
)
{
return false
}
FileAppend(
this.SanitizeField(
channelId
)
. "`t"
. this.SanitizeField(
displayName
)
. "`n",
this.StorageFilePath,
"UTF-8"
)
this.InvalidateCache()
return true
}
Delete(
channelId,
displayName
)
{
normalizedChannelId :=
this.Normalize(
channelId
)
normalizedDisplayName :=
this.Normalize(
displayName
)
identityObjects :=
this.LoadAll()
remainingObjects := []
deleted := false
for identityObject in identityObjects
{
currentChannelId :=
this.Normalize(
identityObject[
"ChannelId"
]
)
currentDisplayName :=
this.Normalize(
identityObject[
"DisplayName"
]
)
if (
!deleted
&& currentChannelId
= normalizedChannelId
&& currentDisplayName
= normalizedDisplayName
)
{
deleted := true
continue
}
remainingObjects.Push(
identityObject
)
}
if (!deleted)
{
return false
}
this.SaveAll(
remainingObjects
)
return true
}
IsAdministrator(
channelId := "",
displayName := ""
)
{
normalizedChannelId :=
this.Normalize(
channelId
)
normalizedDisplayName :=
this.Normalize(
displayName
)
if (
normalizedChannelId = ""
&& normalizedDisplayName = ""
)
{
return false
}
identityObjects :=
this.LoadAll()
for identityObject in identityObjects
{
storedChannelId :=
this.Normalize(
identityObject[
"ChannelId"
]
)
storedDisplayName :=
this.Normalize(
identityObject[
"DisplayName"
]
)
if (
normalizedChannelId != ""
&& storedChannelId != ""
&& normalizedChannelId
= storedChannelId
)
{
return true
}
if (
normalizedDisplayName != ""
&& storedDisplayName != ""
&& normalizedDisplayName
= storedDisplayName
)
{
return true
}
}
return false
}
Contains(
normalizedChannelId,
normalizedDisplayName
)
{
identityObjects :=
this.LoadAll()
for identityObject in identityObjects
{
storedChannelId :=
this.Normalize(
identityObject[
"ChannelId"
]
)
storedDisplayName :=
this.Normalize(
identityObject[
"DisplayName"
]
)
if (
normalizedChannelId != ""
&& storedChannelId != ""
&& normalizedChannelId
= storedChannelId
)
{
return true
}
if (
normalizedDisplayName != ""
&& storedDisplayName != ""
&& normalizedDisplayName
= storedDisplayName
)
{
return true
}
}
return false
}
SaveAll(identityObjects)
{
fileText :=
"ChannelId`tDisplayName`n"
for identityObject in identityObjects
{
fileText .=
this.SanitizeField(
identityObject[
"ChannelId"
]
)
. "`t"
. this.SanitizeField(
identityObject[
"DisplayName"
]
)
. "`n"
}
if (FileExist(this.StorageFilePath))
{
FileDelete(
this.StorageFilePath
)
}
FileAppend(
fileText,
this.StorageFilePath,
"UTF-8"
)
this.InvalidateCache()
}
EnsureStorageFile()
{
fileName := ""
directoryPath := ""
SplitPath(
this.StorageFilePath,
&fileName,
&directoryPath
)
if (
directoryPath != ""
&& !DirExist(
directoryPath
)
)
{
DirCreate(
directoryPath
)
}
if (!FileExist(this.StorageFilePath))
{
FileAppend(
"ChannelId`tDisplayName`n",
this.StorageFilePath,
"UTF-8"
)
}
}
Normalize(fieldValue)
{
return StrLower(
Trim(
fieldValue . ""
)
)
}
SanitizeField(fieldValue)
{
fieldText :=
fieldValue . ""
fieldText :=
StrReplace(
fieldText,
"`t",
" "
)
fieldText :=
StrReplace(
fieldText,
"`r",
" "
)
fieldText :=
StrReplace(
fieldText,
"`n",
" "
)
return Trim(fieldText)
}
}
🔵 Engine/Services/EventLogService.ahk — 작업·송출 기록 저장 (TSV) #
;============================================================
; EventLogService
;
; 역할:
; - 관리자 작업과 송출 기록을 UTF-8 TSV로 저장
; - 최근 기록 읽기
; - 로그 전체 비우기
;============================================================
class EventLogService
{
__New(logFilePath)
{
if (
Type(logFilePath) != "String"
|| Trim(logFilePath) = ""
)
{
throw ValueError(
"로그 파일 경로가 필요합니다."
)
}
this.LogFilePath :=
logFilePath
this.EnsureLogFile()
}
Append(
eventType,
details
)
{
cleanEventType :=
this.SanitizeField(
eventType
)
cleanDetails :=
this.SanitizeField(
details
)
timestampText :=
FormatTime(
,
"yyyy-MM-dd HH:mm:ss"
)
FileAppend(
timestampText
. "`t"
. cleanEventType
. "`t"
. cleanDetails
. "`n",
this.LogFilePath,
"UTF-8"
)
return Map(
"Timestamp", timestampText,
"EventType", cleanEventType,
"Details", cleanDetails
)
}
LoadRecent(
maximumCount := 300
)
{
if (
!IsNumber(maximumCount)
|| maximumCount < 1
|| Floor(maximumCount)
!= maximumCount
)
{
throw ValueError(
"최근 로그 개수는 1 이상의 정수여야 합니다.",
-1,
maximumCount
)
}
this.EnsureLogFile()
fileText :=
FileRead(
this.LogFilePath,
"UTF-8"
)
lines :=
StrSplit(
fileText,
"`n",
"`r"
)
eventObjects := []
if (lines.Length <= 1)
{
return eventObjects
}
firstDataIndex :=
Max(
2,
lines.Length
- maximumCount
)
Loop (
lines.Length
- firstDataIndex
+ 1
)
{
lineIndex :=
firstDataIndex
+ A_Index
- 1
lineText :=
Trim(
lines[
lineIndex
]
)
if (lineText = "")
{
continue
}
fields :=
StrSplit(
lineText,
"`t"
)
if (fields.Length < 3)
{
continue
}
eventObjects.Push(
Map(
"Timestamp", fields[1],
"EventType", fields[2],
"Details", fields[3]
)
)
}
return eventObjects
}
Clear()
{
if (FileExist(this.LogFilePath))
{
FileDelete(
this.LogFilePath
)
}
this.EnsureLogFile()
}
GetLogFilePath()
{
return this.LogFilePath
}
EnsureLogFile()
{
fileName := ""
directoryPath := ""
SplitPath(
this.LogFilePath,
&fileName,
&directoryPath
)
if (
directoryPath != ""
&& !DirExist(
directoryPath
)
)
{
DirCreate(
directoryPath
)
}
if (!FileExist(this.LogFilePath))
{
FileAppend(
"Timestamp`tEventType`tDetails`n",
this.LogFilePath,
"UTF-8"
)
}
}
SanitizeField(fieldValue)
{
fieldText :=
String(
fieldValue
)
fieldText :=
StrReplace(
fieldText,
"`t",
" "
)
fieldText :=
StrReplace(
fieldText,
"`r",
" "
)
fieldText :=
StrReplace(
fieldText,
"`n",
" "
)
return Trim(fieldText)
}
}
🟢 Engine · Utilities / Validation — 도구와 입력 검사 #
🟢 Engine/Utilities/Json.ahk — 최소 JSON 파서 #
;============================================================
; Json
;
; 역할:
; - 유튜브 API가 돌려주는 JSON 문자열을
; AHK의 Map / Array 구조로 바꾼다.
; - 이 프로젝트에 필요한 만큼만 구현한 최소 파서이다.
; (문자열, 숫자, true/false/null, 객체, 배열만 지원)
;============================================================
class Json
{
static Parse(jsonText)
{
parserState := {
Text: jsonText,
Position: 1,
Length: StrLen(jsonText)
}
Json.SkipWhitespace(parserState)
resultValue := Json.ParseValue(parserState)
return resultValue
}
static ParseValue(parserState)
{
Json.SkipWhitespace(parserState)
currentCharacter :=
Json.PeekChar(parserState)
if (currentCharacter = "{")
{
return Json.ParseObject(parserState)
}
if (currentCharacter = "[")
{
return Json.ParseArray(parserState)
}
if (currentCharacter = "`"")
{
return Json.ParseString(parserState)
}
if (
currentCharacter = "t"
|| currentCharacter = "f"
)
{
return Json.ParseBoolean(parserState)
}
if (currentCharacter = "n")
{
parserState.Position += 4
return ""
}
return Json.ParseNumber(parserState)
}
static ParseObject(parserState)
{
resultMap := Map()
Json.ExpectChar(parserState, "{")
Json.SkipWhitespace(parserState)
if (Json.PeekChar(parserState) = "}")
{
parserState.Position += 1
return resultMap
}
Loop
{
Json.SkipWhitespace(parserState)
keyText :=
Json.ParseString(parserState)
Json.SkipWhitespace(parserState)
Json.ExpectChar(parserState, ":")
Json.SkipWhitespace(parserState)
valueResult :=
Json.ParseValue(parserState)
resultMap[keyText] := valueResult
Json.SkipWhitespace(parserState)
nextCharacter :=
Json.PeekChar(parserState)
if (nextCharacter = ",")
{
parserState.Position += 1
continue
}
if (nextCharacter = "}")
{
parserState.Position += 1
break
}
throw ValueError(
"JSON 객체 파싱 오류 (위치 "
. parserState.Position
. ")"
)
}
return resultMap
}
static ParseArray(parserState)
{
resultArray := []
Json.ExpectChar(parserState, "[")
Json.SkipWhitespace(parserState)
if (Json.PeekChar(parserState) = "]")
{
parserState.Position += 1
return resultArray
}
Loop
{
valueResult :=
Json.ParseValue(parserState)
resultArray.Push(valueResult)
Json.SkipWhitespace(parserState)
nextCharacter :=
Json.PeekChar(parserState)
if (nextCharacter = ",")
{
parserState.Position += 1
Json.SkipWhitespace(parserState)
continue
}
if (nextCharacter = "]")
{
parserState.Position += 1
break
}
throw ValueError(
"JSON 배열 파싱 오류 (위치 "
. parserState.Position
. ")"
)
}
return resultArray
}
static ParseString(parserState)
{
Json.ExpectChar(parserState, "`"")
resultText := ""
Loop
{
currentCharacter :=
Json.PeekChar(parserState)
if (currentCharacter = "`"")
{
parserState.Position += 1
break
}
if (currentCharacter = "\")
{
parserState.Position += 1
escapeCharacter :=
Json.PeekChar(parserState)
resultText .=
Json.ResolveEscape(
escapeCharacter,
parserState
)
parserState.Position += 1
continue
}
resultText .= currentCharacter
parserState.Position += 1
}
return resultText
}
static ResolveEscape(escapeCharacter, parserState)
{
if (escapeCharacter = "n")
{
return "`n"
}
if (escapeCharacter = "t")
{
return "`t"
}
if (escapeCharacter = "r")
{
return "`r"
}
if (escapeCharacter = "`"")
{
return "`""
}
if (escapeCharacter = "\")
{
return "\"
}
if (escapeCharacter = "/")
{
return "/"
}
if (escapeCharacter = "u")
{
hexText :=
SubStr(
parserState.Text,
parserState.Position + 1,
4
)
parserState.Position += 4
return Chr("0x" . hexText)
}
return escapeCharacter
}
static ParseBoolean(parserState)
{
if (
SubStr(
parserState.Text,
parserState.Position,
4
) = "true"
)
{
parserState.Position += 4
return true
}
parserState.Position += 5
return false
}
static ParseNumber(parserState)
{
startPosition := parserState.Position
Loop
{
currentCharacter :=
Json.PeekChar(parserState)
if (
InStr(
"0123456789+-.eE",
currentCharacter
)
= 0
)
{
break
}
parserState.Position += 1
}
numberText :=
SubStr(
parserState.Text,
startPosition,
parserState.Position - startPosition
)
return Number(numberText)
}
static SkipWhitespace(parserState)
{
Loop
{
if (parserState.Position > parserState.Length)
{
break
}
currentCharacter :=
Json.PeekChar(parserState)
if (
currentCharacter != " "
&& currentCharacter != "`t"
&& currentCharacter != "`n"
&& currentCharacter != "`r"
)
{
break
}
parserState.Position += 1
}
}
static PeekChar(parserState)
{
if (parserState.Position > parserState.Length)
{
return ""
}
return SubStr(
parserState.Text,
parserState.Position,
1
)
}
static ExpectChar(parserState, expectedCharacter)
{
actualCharacter :=
Json.PeekChar(parserState)
if (actualCharacter != expectedCharacter)
{
throw ValueError(
"JSON 파싱 오류: '"
. expectedCharacter
. "'를 기대했지만 '"
. actualCharacter
. "'를 만남 (위치 "
. parserState.Position
. ")"
)
}
parserState.Position += 1
}
}
🟢 Engine/Utilities/ValueParser.ahk — 설정 문자열을 Boolean·정수·색상 등으로 변환 #
;============================================================
; ValueParser
;
; 설정 파일에서 읽은 문자열 값을
; 검증된 Boolean, Integer, Number, Color 값으로 변환한다.
;
; 파일 읽기, 설정 저장, 스타일 결정은 담당하지 않는다.
;============================================================
class ValueParser
{
;--------------------------------------------------------
; ParseBoolean()
;
; 참으로 처리하는 값:
; True, Yes, On, 1
;
; 거짓으로 처리하는 값:
; False, No, Off, 0
;--------------------------------------------------------
static ParseBoolean(value, displayName := "논리값")
{
if (IsNumber(value))
{
if (value = 1)
return true
if (value = 0)
return false
}
if (Type(value) != "String")
{
throw TypeError(
displayName
. "은 논리 형식이어야 합니다."
)
}
normalizedValue := StrLower(
Trim(value)
)
switch normalizedValue
{
case "true", "yes", "on", "1":
return true
case "false", "no", "off", "0":
return false
}
throw ValueError(
displayName
. "은 True, False, Yes, No, On, Off, 1, 0 중 하나여야 합니다.",
-1,
value
)
}
;--------------------------------------------------------
; ParseInteger()
; 정수 형식의 값을 Integer로 변환한다.
;--------------------------------------------------------
static ParseInteger(value, displayName := "정수값")
{
valueText := this.NormalizeValueText(
value,
displayName
)
if !RegExMatch(
valueText,
"^-?\d+$"
)
{
throw ValueError(
displayName
. "은 정수여야 합니다.",
-1,
value
)
}
return Integer(valueText)
}
;--------------------------------------------------------
; ParseNumber()
; 정수 또는 소수 형식의 값을 Number로 변환한다.
;--------------------------------------------------------
static ParseNumber(value, displayName := "숫자값")
{
valueText := this.NormalizeValueText(
value,
displayName
)
if !RegExMatch(
valueText,
"^-?(?:\d+|\d+\.\d+|\.\d+)$"
)
{
throw ValueError(
displayName
. "은 숫자여야 합니다.",
-1,
value
)
}
return Number(valueText)
}
;--------------------------------------------------------
; ParsePositiveNumber()
; 0보다 큰 숫자로 변환한다.
;--------------------------------------------------------
static ParsePositiveNumber(
value,
displayName := "양수값"
)
{
parsedValue := this.ParseNumber(
value,
displayName
)
if (parsedValue <= 0)
{
throw ValueError(
displayName
. "은 0보다 커야 합니다.",
-1,
value
)
}
return parsedValue
}
;--------------------------------------------------------
; ParseNonNegativeNumber()
; 0 이상의 숫자로 변환한다.
;--------------------------------------------------------
static ParseNonNegativeNumber(
value,
displayName := "0 이상 숫자값"
)
{
parsedValue := this.ParseNumber(
value,
displayName
)
if (parsedValue < 0)
{
throw ValueError(
displayName
. "은 0 이상이어야 합니다.",
-1,
value
)
}
return parsedValue
}
;--------------------------------------------------------
; ParseByte()
; 0~255 범위의 정수로 변환한다.
;
; Opacity 등의 값에 사용한다.
;--------------------------------------------------------
static ParseByte(value, displayName := "바이트값")
{
parsedValue := this.ParseInteger(
value,
displayName
)
if (
parsedValue < 0
|| parsedValue > 255
)
{
throw ValueError(
displayName
. "은 0부터 255 사이여야 합니다.",
-1,
value
)
}
return parsedValue
}
;--------------------------------------------------------
; ParseFontWeight()
; 1~1000 범위의 글꼴 굵기로 변환한다.
;--------------------------------------------------------
static ParseFontWeight(
value,
displayName := "글꼴 굵기"
)
{
parsedValue := this.ParseInteger(
value,
displayName
)
if (
parsedValue < 1
|| parsedValue > 1000
)
{
throw ValueError(
displayName
. "는 1부터 1000 사이여야 합니다.",
-1,
value
)
}
return parsedValue
}
;--------------------------------------------------------
; ParseColor()
; RRGGBB 형식의 색상 문자열로 변환한다.
;
; 소문자로 입력해도 대문자로 정규화한다.
;--------------------------------------------------------
static ParseColor(value, displayName := "색상")
{
valueText := this.NormalizeValueText(
value,
displayName
)
if !RegExMatch(
valueText,
"i)^[0-9A-F]{6}$"
)
{
throw ValueError(
displayName
. "은 RRGGBB 형식이어야 합니다.",
-1,
value
)
}
return StrUpper(valueText)
}
;--------------------------------------------------------
; ParseRequiredText()
; 비어 있지 않은 문자열로 변환한다.
;
; FontName, FallbackType 등에 사용한다.
;--------------------------------------------------------
static ParseRequiredText(
value,
displayName := "문자열 값"
)
{
if (Type(value) != "String")
{
throw TypeError(
displayName
. "은 문자열이어야 합니다."
)
}
normalizedValue := Trim(value)
if (normalizedValue = "")
{
throw ValueError(
displayName
. "은 비어 있을 수 없습니다."
)
}
return normalizedValue
}
;--------------------------------------------------------
; NormalizeValueText()
; 숫자 또는 문자열 값을 검사하고 문자열로 정규화한다.
;--------------------------------------------------------
static NormalizeValueText(value, displayName)
{
if (
Type(value) != "String"
&& !IsNumber(value)
)
{
throw TypeError(
displayName
. "은 문자열 또는 숫자여야 합니다."
)
}
valueText := Trim(value)
if (valueText = "")
{
throw ValueError(
displayName
. "은 비어 있을 수 없습니다."
)
}
return valueText
}
}
🟢 Engine/Validation/MessageTextValidator.ahk — 메시지 입력 검사 (빈 값·길이·괄호·따옴표) #
;============================================================
; MessageTextValidator
;
; Build 2.3
;
; 로컬 기본 입력 검사:
; - 빈 메시지
; - 최대 길이
; - 괄호 짝
; - 큰따옴표 짝
; - 연속 공백 정리
; - 과도한 문장부호 경고
;
; 주의:
; 자연어 AI 문법 교정기가 아니라
; 송출 전 실수 방지용 기본 검사기이다.
;============================================================
class MessageTextValidator
{
__New(maximumLength := 300)
{
if (!IsNumber(maximumLength))
{
throw TypeError(
"최대 메시지 길이는 숫자여야 합니다."
)
}
if (
maximumLength <= 0
|| Floor(maximumLength) != maximumLength
)
{
throw ValueError(
"최대 메시지 길이는 1 이상의 정수여야 합니다.",
-1,
maximumLength
)
}
this.MaximumLength := maximumLength
}
Validate(messageText)
{
if (Type(messageText) != "String")
{
return this.CreateFailure(
"메시지는 문자열이어야 합니다."
)
}
normalizedText :=
this.NormalizeText(
messageText
)
if (normalizedText = "")
{
return this.CreateFailure(
"메시지를 입력하세요."
)
}
if (StrLen(normalizedText) > this.MaximumLength)
{
return this.CreateFailure(
"글자수 초과"
)
}
bracketResult :=
this.ValidateBrackets(
normalizedText
)
if (!bracketResult["IsValid"])
{
return bracketResult
}
quoteResult :=
this.ValidateDoubleQuotes(
normalizedText
)
if (!quoteResult["IsValid"])
{
return quoteResult
}
warnings := []
if (
RegExMatch(
normalizedText,
"[!?.,]{4,}"
)
)
{
warnings.Push(
"문장부호"
)
}
if (normalizedText != Trim(messageText))
{
warnings.Push(
"연속공백 정리"
)
}
resultMessage :=
"검사 통과."
if (warnings.Length > 0)
{
resultMessage .=
" 주의: "
. this.JoinWarnings(
warnings
)
}
return Map(
"IsValid", true,
"NormalizedText", normalizedText,
"Message", resultMessage,
"Warnings", warnings
)
}
NormalizeText(messageText)
{
normalizedText :=
StrReplace(
messageText,
"`r`n",
" "
)
normalizedText :=
StrReplace(
normalizedText,
"`r",
" "
)
normalizedText :=
StrReplace(
normalizedText,
"`n",
" "
)
normalizedText :=
RegExReplace(
normalizedText,
"[ `t]+",
" "
)
return Trim(normalizedText)
}
ValidateBrackets(messageText)
{
openingBrackets :=
Map(
"(", true,
"[", true,
"{", true
)
closingPairs :=
Map(
")", "(",
"]", "[",
"}", "{"
)
bracketStack := []
Loop Parse messageText
{
currentCharacter := A_LoopField
if (openingBrackets.Has(currentCharacter))
{
bracketStack.Push(
currentCharacter
)
continue
}
if (!closingPairs.Has(currentCharacter))
{
continue
}
if (bracketStack.Length = 0)
{
return this.CreateFailure(
"문장부호 오류"
)
}
expectedOpening :=
closingPairs[
currentCharacter
]
actualOpening :=
bracketStack[
bracketStack.Length
]
if (actualOpening != expectedOpening)
{
return this.CreateFailure(
"문장부호 오류"
)
}
bracketStack.Pop()
}
if (bracketStack.Length > 0)
{
return this.CreateFailure(
"문장부호 오류"
)
}
return this.CreateSuccess(
messageText
)
}
ValidateDoubleQuotes(messageText)
{
quoteCount := 0
quoteCharacter := Chr(34)
Loop Parse messageText
{
if (A_LoopField = quoteCharacter)
{
quoteCount += 1
}
}
if (Mod(quoteCount, 2) != 0)
{
return this.CreateFailure(
"문장부호 오류"
)
}
return this.CreateSuccess(
messageText
)
}
CreateSuccess(normalizedText)
{
return Map(
"IsValid", true,
"NormalizedText", normalizedText,
"Message", "",
"Warnings", []
)
}
CreateFailure(errorMessage)
{
return Map(
"IsValid", false,
"NormalizedText", "",
"Message", errorMessage,
"Warnings", []
)
}
JoinWarnings(warnings)
{
resultText := ""
for warningIndex, warningText in warnings
{
if (warningIndex > 1)
{
resultText .= " / "
}
resultText .= warningText
}
return resultText
}
}
🟠 Data — 설정 파일 #
🟠 Data/Settings/Overlay.ini — 송출 창 크기·위치·여백·표시 개수 설정 #
[Overlay]
X=20
Y=20
Width=1000
Height=1040
[Layout]
LeftMargin=56
TopMargin=72
BottomMargin=20
MaximumTextWidth=940
MinimumMessageHeight=28
MessageGap=2
TextLineGap=2
[Messages]
BufferCapacity=20
VisibleMessageLimit=10
[Window]
AlwaysOnTop=True
ClickThrough=True
🟠 Data/Settings/Styles.ini — 메시지 종류별 글꼴·색·효과 설정 #
[Defaults]
FallbackType=normal
FontName=이사만루체 Medium
FontSize=24
FontWeight=400
Color=FFFFFF
Opacity=255
ShadowEnabled=True
ShadowColor=000000
ShadowOffsetX=2
ShadowOffsetY=2
OutlineEnabled=True
OutlineColor=000000
OutlineSize=1
GlowEnabled=False
GlowColor=FFFFFF
GlowSize=0
GlowOpacity=0
[Style.normal]
FontWeight=700
Color=87CEEB
OutlineSize=2
GlowEnabled=True
GlowColor=FFA500
GlowSize=2
GlowOpacity=75
[Style.admin]
FontSize=24
FontWeight=700
Color=00FF7F
Opacity=255
ShadowEnabled=True
ShadowColor=000000
ShadowOffsetX=2
ShadowOffsetY=2
OutlineEnabled=True
OutlineColor=000000
OutlineSize=2
GlowEnabled=True
GlowColor=DFFF80
GlowSize=2
GlowOpacity=180
[Style.member]
FontSize=24
FontWeight=700
Color=FF8C00
Opacity=255
ShadowEnabled=True
ShadowColor=000000
ShadowOffsetX=2
ShadowOffsetY=2
[Style.donation]
FontSize=24
FontWeight=700
Color=00BFFF
OutlineSize=2
GlowEnabled=True
GlowColor=00BFFF
GlowSize=3
GlowOpacity=110
[Style.notice]
FontWeight=700
Color=C0C0C0
OutlineSize=2
GlowEnabled=True
GlowColor=FFA500
GlowSize=2
GlowOpacity=75
[Style.system]
FontWeight=700
Color=FF6B6B
OutlineSize=2
GlowEnabled=True
GlowColor=FFA500
GlowSize=2
GlowOpacity=75
[Style.vip]
FontSize=24
FontWeight=800
Color=FFD700
Opacity=255
ShadowEnabled=True
ShadowColor=000000
ShadowOffsetX=2
ShadowOffsetY=2
OutlineEnabled=True
OutlineColor=000000
OutlineSize=2
GlowEnabled=True
GlowColor=FFF2A8
GlowSize=3
GlowOpacity=120
🟠 Data/Settings/YoutubeApiKey.ini — 유튜브 API 키 (본문에서는 가림) #
[Youtube]
ApiKey=여기에_본인의_YouTube_Data_API_키
🟠 Data/Settings/AdminIdentities.tsv — 관리자 ID 목록 (본문에서는 예시로 대체) #
ChannelId DisplayName
UCxxxxxxxxxxxxxxxxxxxxxx 관리자닉네임
채널ID없이_이름만_등록해도_됩니다
탭(Tab)으로 칸을 나눈 UTF-8 파일입니다. 첫 줄은 머리글이고, 채널 ID 칸은 비워 두고 표시 이름만 적어도 됩니다.
🟠 Data/Macros/ChatMacros.tsv — 채팅 매크로 목록 (본문에서는 예시로 대체) #
# OBSLiveChat Chat Macros UTF-8
20260101090000_00000001_000001 인사 인사 관리자닉네임 Admin 안녕하세요
20260101090100_00000002_000002 인사 후원감사 관리자닉네임 Donation 후원 감사합니다.
칸 순서는
ID·카테고리·매크로 이름·작성자·메시지 종류·메시지이고, 종류는NormalAdminMemberDonationNoticeSystemVIP중 하나입니다. 관리자 창에서 등록하면 자동으로 채워집니다.
🟡 Docs — 설계 문서 #
🟡 Docs/ARCHITECTURE.md — 구조 설계 문서 #
# Architecture
## 전체 구조
```text
OBSLiveChat
│
├─ Main.ahk
├─ Config.ini
│
├─ App
│ ├─ Core
│ │ ├─ Application.ahk
│ │ └─ WindowManager.ahk
│ │
│ └─ Windows
│ ├─ AdminWindow.ahk
│ ├─ OverlayWindow.ahk
│ └─ LogWindow.ahk
│
├─ Engine
│ ├─ Model
│ ├─ Pipeline
│ ├─ Rendering
│ ├─ Providers
│ ├─ Macro
│ ├─ Configuration
│ └─ Logging
│
├─ Tests
├─ Docs
├─ Assets
├─ Data
└─ Logs
```
## 의존성 방향
```text
Main
↓
Application
↓
WindowManager
↓
App Windows
↓
Engine
```
허용되는 방향은 다음과 같다.
```text
App → Engine
```
다음 방향은 허용하지 않는다.
```text
Engine → App
```
Engine의 클래스는 GUI, Window, 버튼 또는 관리자 창의 존재를 알지 못한다.
## 메시지 처리 흐름
```text
Provider 또는 관리자 입력
↓
ChatMessage
↓
MessageBuffer
↓
Pipeline
↓
TextDrawCommand
↓
OverlayRenderer
↓
OverlayWindow
```
### Model
프로그램이 처리하는 데이터를 표현하고 보관한다.
예:
* `ChatMessage`
* `MessageBuffer`
* 향후 `User`
* 향후 `Channel`
Model은 화면에 직접 그리지 않는다.
### Pipeline
Model 데이터를 렌더러가 실행할 수 있는 명령으로 변환한다.
예:
* 향후 `MessageFactory`
* 향후 `MessageFilter`
* 향후 `TextLayout`
* `TextDrawCommand`
Pipeline은 실제 Windows GDI 함수를 호출하지 않는다.
### Rendering
Pipeline에서 만들어진 그리기 명령을 화면에 실행한다.
예:
* `OverlayRenderer`
* 향후 `FontManager`
* 향후 `AnimationManager`
Rendering은 메시지가 관리자 채팅인지 일반 채팅인지 판단하지 않는다.
### Providers
YouTube 등의 외부 서비스와 연결한다.
Provider가 받아온 외부 데이터는 내부 Model 객체로 변환된 뒤 Pipeline으로 전달된다.
### App
프로그램 창과 사용자 입력을 담당한다.
App는 Engine을 사용하지만, 핵심 데이터를 직접 보관하지 않는다.
## 현재 구현 상태
```text
Application 완료
WindowManager 완료
AdminWindow 완료
OverlayWindow 완료
OverlayRenderer 기본 렌더링 완료
ChatMessage 완료
MessageBuffer 완료
TextDrawCommand Build 0.6에서 추가
TextLayout 다음 단계
```
## 설계 금지 사항
다음 구조는 사용하지 않는다.
* Window가 메시지 배열을 직접 소유하는 구조
* Provider가 OverlayWindow를 직접 호출하는 구조
* Renderer가 사용자 종류나 메시지 종류를 판단하는 구조
* `Utils.ahk`, `Common.ahk`, `Misc.ahk`에 관련 없는 기능을 모으는 구조
* Main.ahk에 프로그램 로직을 작성하는 구조
* Engine에서 App의 클래스를 Include하는 구조
🟡 Docs/CODING_STANDARD.md — 코딩 규칙 문서 #
# Coding Standard
## 기본 환경
* AutoHotkey v2 문법만 사용한다.
* 모든 실행 진입점에 `#Requires AutoHotkey v2.0+`를 작성한다.
* 개발 중에는 `#Warn All`을 사용한다.
* AutoHotkey v1 문법을 혼용하지 않는다.
## 파일명과 클래스명
파일명과 대표 클래스명은 동일하게 작성한다.
```text
ChatMessage.ahk → class ChatMessage
MessageBuffer.ahk → class MessageBuffer
OverlayRenderer.ahk → class OverlayRenderer
```
클래스명은 `PascalCase`를 사용한다.
```text
AdminWindow
TextDrawCommand
WindowManager
```
의미를 알 수 없는 축약어는 사용하지 않는다.
## 변수명
지역 변수는 `camelCase`를 사용한다.
```ahk
messageCount
fontSize
windowHandle
```
객체의 멤버는 `this.`로 명확하게 표시한다.
```ahk
this.FontName
this.MessageBufferInstance
```
AutoHotkey는 이름의 대소문자를 구분하지 않으므로 클래스명과 변수명을 대소문자만 다르게 작성하지 않는다.
금지:
```ahk
messageBuffer := MessageBuffer()
```
권장:
```ahk
messageBufferInstance := MessageBuffer()
```
## 함수명
함수와 메서드는 `PascalCase`를 사용한다.
함수 이름은 동사로 시작한다.
```text
Add()
Clear()
Render()
Build()
Show()
Hide()
RequestRedraw()
```
이벤트 처리 함수는 `Handle`로 시작한다.
```text
HandleClose()
HandleResize()
HandleClick()
```
## 클래스 역할 접미사
역할에 맞는 접미사를 일관되게 사용한다.
```text
Window 창
Manager 여러 객체나 기능의 관리
Provider 외부 서비스 연결
Renderer 화면 출력
Buffer 제한된 데이터 보관
Command 실행할 명령 데이터
Factory 객체 생성과 변환
```
## 주석
주석은 코드가 무엇을 하는지 반복하기보다, 왜 그렇게 설계했는지를 설명한다.
클래스 상단에는 책임과 금지 범위를 기록한다.
```ahk
;============================================================
; MessageBuffer
;
; 최근 메시지를 제한된 개수만 보관한다.
; 화면 출력과 메시지 스타일 판단은 담당하지 않는다.
;============================================================
```
메서드 상단에는 목적과 중요한 동작을 기록한다.
```ahk
;------------------------------------------------------------
; GetMessages()
; 내부 배열이 외부에서 수정되지 않도록 복사본을 반환한다.
;------------------------------------------------------------
```
## 클래스 크기
* 약 300줄을 넘으면 책임 분리를 검토한다.
* 약 500줄을 넘으면 적극적으로 분리한다.
* 1,000줄 규모의 클래스는 허용하지 않는다.
줄 수만으로 무조건 나누지는 않으며, 클래스가 여러 책임을 갖는지를 우선 확인한다.
## 예외 처리
잘못된 인수를 조용히 무시하지 않는다.
호출한 쪽의 실수를 바로 알 수 있도록 구체적인 예외를 발생시킨다.
```ahk
if (maximumMessageCount < 1)
throw ValueError("최대 메시지 개수는 1 이상이어야 합니다.")
```
## Include 규칙
* 필요한 파일만 Include한다.
* 의존 대상은 사용하는 클래스보다 먼저 Include한다.
* Engine은 App 파일을 Include하지 않는다.
* 순환 Include가 생기지 않도록 한다.
## 테스트 규칙
Engine에 새로운 독립 기능을 추가하면 `Tests`에 대응하는 테스트 파일을 만든다.
```text
MessageBuffer.ahk
MessageBufferTest.ahk
TextDrawCommand.ahk
TextDrawCommandTest.ahk
```
테스트가 성공한 뒤에 실제 프로그램에 연결한다.
## 금지 파일
관련 없는 코드를 한곳에 모으는 다음 파일은 만들지 않는다.
```text
Common.ahk
Utils.ahk
Helper.ahk
Misc.ahk
```
공통 기능이 필요하면 역할에 맞는 명확한 클래스나 파일로 분리한다.
🟡 Docs/PROJECT_PHILOSOPHY.md — 프로젝트 철학 문서 #
# OBS Live Chat Studio
## Project Philosophy
> 좋은 구조는 좋은 기능보다 오래 살아남는다.
OBS Live Chat Studio는 AutoHotkey v2로 제작하는 OBS용 실시간 채팅 관리 및 오버레이 프로그램이다.
이 프로젝트의 목표는 단순히 작동하는 스크립트를 만드는 것이 아니다. 시간이 지나도 구조를 이해할 수 있고, 기능을 안전하게 확장할 수 있으며, 다른 개발자가 코드를 읽어도 각 구성 요소의 역할을 알 수 있는 소프트웨어를 만든다.
## 핵심 원칙
### 1. 한 클래스는 하나의 책임만 가진다
창은 창을 관리하고, 렌더러는 그리기만 하며, 버퍼는 메시지를 보관한다.
여러 책임이 한 클래스에 섞이면 기능을 추가할 때 기존 코드가 쉽게 깨진다.
### 2. 좋은 이름은 좋은 주석보다 강하다
파일명, 클래스명, 함수명, 변수명만 읽어도 역할을 짐작할 수 있어야 한다.
의미를 알 수 없는 축약어와 임시 이름은 사용하지 않는다.
### 3. App는 Engine을 사용한다
`App`는 창과 사용자 인터페이스를 담당한다.
`Engine`은 메시지 처리, 배치, 렌더링, 외부 서비스 연결과 같은 핵심 기능을 담당한다.
`Engine`은 `App`의 존재를 알지 못해야 한다.
### 4. Renderer는 판단하지 않는다
Renderer는 관리자, 일반 사용자, 후원자와 같은 메시지 의미를 판단하지 않는다.
Renderer는 전달받은 그리기 명령을 화면에 실행하기만 한다.
### 5. 데이터와 화면을 분리한다
채팅 메시지는 Window나 GUI 컨트롤에 저장하지 않는다.
데이터는 Model에서 관리하고, 화면 표시는 Pipeline과 Rendering 계층을 거쳐 처리한다.
### 6. 기능은 작은 단위로 검증한다
새 기능은 실제 프로그램에 연결하기 전에 독립 테스트를 작성한다.
테스트에 성공한 기능만 다음 단계로 연결한다.
### 7. 안정된 Build를 보존한다
각 Build가 성공하면 전체 프로젝트를 백업한다.
새로운 기능이 실패해도 마지막 안정 버전으로 돌아갈 수 있어야 한다.
### 8. 필요한 만큼만 설계한다
확장 가능성은 고려하지만, 아직 필요하지 않은 빈 클래스와 복잡한 계층을 미리 만들지 않는다.
확실히 필요한 구조는 준비하되 구현은 필요한 시점에 시작한다.
## 프로젝트 목표
* OBS에서 안정적으로 캡처되는 투명 채팅 오버레이
* YouTube 라이브 채팅 수신 및 송신
* 관리자 계정과 일반 사용자의 명확한 구분
* 매크로 등록, 분류, 검색 및 원클릭 전송
* 글꼴, 색상, 외곽선, 그림자와 애니메이션 지원
* 로그 검색과 문제 진단
* 새로운 플랫폼을 추가하기 쉬운 Provider 구조
* 초보자도 구조와 코드를 따라갈 수 있는 문서화
## 최종 기준
작동한다고 끝난 것이 아니다.
다음 질문에 모두 긍정적으로 답할 수 있어야 완성된 코드로 판단한다.
* 이름이 명확한가?
* 파일 위치가 적절한가?
* 책임이 하나인가?
* 나중에 기능을 붙이기 쉬운가?
* 다른 사람이 봐도 이해할 수 있는가?
* 독립적으로 테스트할 수 있는가?
우리는 예제가 아니라 실제로 사용할 수 있는 제품을 만든다.
🟣 빌드 노트 — README #
🟣 README_Build_1.9.txt — Build 1.9 노트 #
OBSLiveChat Build 1.9 - Layered Alpha Foundation
이번 단계는 Glow 구현 전의 기반 테스트입니다.
추가 파일
1. Engine\Rendering\LayeredOverlayRenderer.ahk
2. Tests\LayeredOverlayTest.ahk
기존 안정 파일은 덮어쓰지 않습니다.
기존 OverlayRenderer.ahk도 그대로 둡니다.
실행
Tests\LayeredOverlayTest.ahk
정상 결과
- 검은 사각형 없음
- 배경 완전 투명
- 글자 색상 정상
- Shadow 정상
- Outline 정상
- System Opacity=220이 반투명으로 표시
- 마우스 클릭 통과
- Esc 종료
중요
이 테스트에서는 WinSetTransColor를 사용하지 않습니다.
Glow는 이 기반 테스트가 성공한 다음 단계에서 추가합니다.
🟣 README_Build_1.9.1.txt — Build 1.9.1 노트 #
OBSLiveChat Build 1.9.1 - Layered Glow
적용 전:
Build_1.9_LayeredAlpha_Stable 백업 권장
덮어쓸 파일:
1. Engine\Rendering\TextStyle.ahk
2. Engine\Configuration\StyleLoader.ahk
3. Engine\Pipeline\TextLayout.ahk
4. Engine\Rendering\LayeredOverlayRenderer.ahk
5. Data\Settings\Styles.ini
새 테스트:
6. Tests\LayeredGlowOverlayTest.ahk
변경 사항:
- UpdateLayeredWindow 픽셀별 Alpha 유지
- WinSetTransColor 사용 안 함
- GlowEnabled
- GlowColor
- GlowSize
- GlowOpacity
- Glow 크기를 TextLayout 높이에 반영
- 기존 TextStyle 생성 코드 호환용 Glow 인수 기본값 제공
렌더링 순서:
Glow -> Shadow -> Outline -> Main Text
VIP:
Color=FFD700
OutlineColor=000000
GlowColor=FFFFFF
GlowSize=4
GlowOpacity=150
실행:
Tests\LayeredGlowOverlayTest.ahk
정상 결과:
- 검은 사각형 없음
- 색상 왜곡 없음
- 관리자 금색 Glow
- 후원 하늘색 Glow
- 공지 주황색 Glow
- VIP 금색 본문 + White Glow
- 줄 겹침 없음
- 클릭 통과
- Esc 종료
🟣 README_Build_1.9.2.txt — Build 1.9.2 노트 #
OBSLiveChat Build 1.9.2 - Layered Glow Fix
수정 내용
- LayeredOverlayRenderer.StartGdiPlus()에서
gdiPlusToken := 0 초기화 추가
- AutoHotkey v2의 'local variable has not been assigned' 오류 해결
적용
압축 내용을 OBSLiveChat 폴더에 덮어쓰기
실행
Tests\LayeredGlowOverlayTest.ahk
기대 결과
- GDI+ 시작 오류 없음
- 검은 사각형 없음
- 색상 왜곡 없음
- Layered Alpha Glow 출력
🟣 README_Build_2.0.txt — Build 2.0 노트 (자동 줄바꿈) #
OBSLiveChat Build 2.0 - Auto Wrap
목적
- 긴 메시지가 오른쪽으로 잘리는 문제 해결
- 메시지 안에서 자동 줄바꿈
- 여러 줄의 실제 높이를 다음 메시지 위치에 반영
- 긴 URL/공백 없는 문자열도 글자 단위 분할
- Layered Alpha, Glow, Shadow, Outline 유지
덮어쓸 파일
1. Engine\Rendering\TextDrawCommand.ahk
2. Engine\Pipeline\TextLayout.ahk
3. Engine\Rendering\LayeredOverlayRenderer.ahk
새 테스트
4. Tests\AutoWrapOverlayTest.ahk
기존 TextLayout 호출은 그대로 작동합니다.
자동 줄바꿈을 사용할 때만 여섯 번째와 일곱 번째 인수를 전달합니다.
예:
TextLayout(
50,
35,
40,
14,
textMeasurer,
760,
5
)
760 = 최대 텍스트 폭
5 = 한 메시지 내부 줄 간격
실행
Tests\AutoWrapOverlayTest.ahk
성공 기준
- 긴 문장이 2줄 이상으로 자동 분리
- 오른쪽 잘림 없음
- 다음 메시지와 겹침 없음
- Glow/Shadow/Outline가 모든 줄에 적용
- 긴 URL도 화면 폭 안에서 분리
🟣 README_Build_2.0.1.txt — Build 2.0.1 노트 (종료 오류 수정) #
OBSLiveChat Build 2.0.1 - Shutdown Fix
증상
- 프로그램 종료 시 LayeredOverlayRenderer.__Delete()
- GdiplusShutdown에서 0xc0000005 예외
원인
- AutoHotkey 종료 과정의 __Delete() 안에서
GDI+ 네이티브 종료 함수를 호출함
- 인터프리터 종료 시점에는 DLL/객체 정리 순서가 안전하지 않음
수정
1. __Delete()에서는 GdiplusShutdown을 호출하지 않음
2. Shutdown()은 중복 호출 방지를 위해 상태를 먼저 초기화
3. 기존 Esc 종료 코드는 그대로 사용
덮어쓸 파일
Engine\Rendering\LayeredOverlayRenderer.ahk
테스트
Tests\AutoWrapOverlayTest.ahk 실행
Esc로 종료했을 때 예외 메시지가 없어야 함
🟣 README_Recovery.txt — 복구 패치 노트 #
OBSLiveChat 복구 패치
기준: 사용자가 올린 현재 파일 + Build 2.0 백업의 Styles.ini
확인된 원인
1. Styles_OBS20.ini
현재 Application.ahk가 정식 Styles.ini가 아니라
Data\Settings\Styles_OBS20.ini를 읽고 있었습니다.
수정:
- Application.ahk가 Data\Settings\Styles.ini만 읽도록 변경
- Styles.ini는 Build 2.0 백업에 들어 있던 원본을 그대로 복원
- VIP도 Build 2.0 원본값인 밝은 황금색과 Glow를 그대로 사용
2. Enter 후 이전 문장의 마지막 글자가 남는 현상
현재 Main.ahk가 Enter KeyDown을 즉시 가로챘습니다.
한글 IME가 마지막 조합 글자를 확정하기 전에 입력창을 지우면,
IME가 마지막 글자를 비워진 입력창에 나중에 다시 기록할 수 있습니다.
수정:
- Enter KeyDown을 가로채지 않음
- Enter KeyUp에서 한 번만 처리
- 30ms 뒤 메시지를 읽음
- 메시지 입력폼에 포커스가 있을 때만 송출
3. 표시 개수 '적용' 버튼
현재 이 버튼에 Default 속성이 붙어 있었습니다.
관리자 창에서 Enter 동작과 충돌할 가능성이 있어 제거했습니다.
카테고리 삭제 확인창의 '취소 Default'는 그대로 유지했습니다.
4. 응답 없음/루프 여부
업로드한 LayeredOverlayRenderer.ahk에는 무한 루프나 반복 Timer가 없습니다.
Glow와 Outline의 Loop는 설정된 반지름만큼 끝나는 유한 반복입니다.
현재 렌더러는 2.8.2의 창 위치/크기 원자 적용과 종료 충돌 회피가 있으므로
Build 2.0 렌더러로 되돌리지 않고 그대로 유지합니다.
덮어쓸 파일
1. Main.ahk
2. App\Core\Application.ahk
3. App\Windows\AdminWindow.ahk
4. Data\Settings\Styles.ini
덮어쓰지 않는 파일
Engine\Rendering\LayeredOverlayRenderer.ahk
실행 순서
1. 현재 OBSLiveChat 완전 종료
2. 위 네 파일 덮어쓰기
3. Main.ahk 실행
4. 한글 문장 입력 후 Enter
5. 입력창에 이전 문장의 마지막 글자가 남지 않는지 확인
6. VIP 밝은 황금색 및 Glow 확인
Styles_OBS20.ini
프로그램에서는 더 이상 읽지 않습니다.
복구 확인 전에는 파일을 삭제하지 않아도 됩니다.
🔴 Tests — 단독 테스트 #
기능을 프로그램에 붙이기 전에 하나씩 따로 검증하려고 만든 테스트 스크립트들입니다. 파일마다 단독으로 실행할 수 있습니다.
🔴 Tests/AdaptiveTextLayoutTest.ahk — 실제 글자 폭·높이로 메시지가 겹치지 않는지 검사 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Services\MessagePipeline.ahk
;============================================================
; AdaptiveTextLayoutTest
;
; 실제 GDI 측정 폭과 높이를 사용하여
; 메시지들이 화면에서 겹치지 않는지 검사한다.
;============================================================
;------------------------------------------------------------
; 스타일 설정 불러오기
;------------------------------------------------------------
adaptiveLayoutTestStylePath :=
A_ScriptDir "\..\Data\Settings\Styles.ini"
adaptiveLayoutTestStyleLoader :=
StyleLoader()
adaptiveLayoutTestStyleConfiguration :=
adaptiveLayoutTestStyleLoader.Load(
adaptiveLayoutTestStylePath
)
adaptiveLayoutTestStyleResolver :=
StyleResolver(
adaptiveLayoutTestStyleConfiguration
)
;------------------------------------------------------------
; 메시지 엔진 객체 생성
;
; 반드시 아래 순서를 지킨다.
;
; 1. MessageBuffer
; 2. GdiTextMeasurer
; 3. TextLayout
; 4. MessagePipeline
;------------------------------------------------------------
adaptiveLayoutTestMessageBuffer :=
MessageBuffer(20)
adaptiveLayoutTestMeasurer :=
GdiTextMeasurer()
adaptiveLayoutTestLayout :=
TextLayout(
30,
40,
40,
12,
adaptiveLayoutTestMeasurer
)
adaptiveLayoutTestPipeline :=
MessagePipeline(
adaptiveLayoutTestMessageBuffer,
adaptiveLayoutTestStyleResolver,
adaptiveLayoutTestLayout
)
;------------------------------------------------------------
; 테스트 메시지 추가
;------------------------------------------------------------
adaptiveLayoutTestMessageBuffer.Add(
ChatMessage(
"일반 사용자",
"일반 메시지입니다.",
"Normal"
)
)
adaptiveLayoutTestMessageBuffer.Add(
ChatMessage(
"관리자",
"관리자 메시지입니다.",
"Admin"
)
)
adaptiveLayoutTestMessageBuffer.Add(
ChatMessage(
"멤버",
"멤버 메시지입니다.",
"Member"
)
)
adaptiveLayoutTestMessageBuffer.Add(
ChatMessage(
"후원자",
"후원 메시지입니다.",
"Donation"
)
)
adaptiveLayoutTestMessageBuffer.Add(
ChatMessage(
"공지",
"공지 메시지입니다.",
"Notice"
)
)
adaptiveLayoutTestMessageBuffer.Add(
ChatMessage(
"시스템",
"시스템 메시지입니다.",
"System"
)
)
adaptiveLayoutTestMessageBuffer.Add(
ChatMessage(
"VIP",
"VIP 메시지입니다.",
"VIP"
)
)
;------------------------------------------------------------
; DrawCommand 생성
;------------------------------------------------------------
adaptiveLayoutTestDrawCommands :=
adaptiveLayoutTestPipeline.BuildDrawCommands()
;------------------------------------------------------------
; 실제 측정값 검사
;------------------------------------------------------------
adaptiveLayoutTestMeasurementPassed := true
adaptiveLayoutTestNoOverlapPassed := true
adaptiveLayoutTestDetails := ""
for adaptiveLayoutTestCommandIndex,
adaptiveLayoutTestDrawCommand
in adaptiveLayoutTestDrawCommands
{
if (!adaptiveLayoutTestDrawCommand.HasMeasurement())
{
adaptiveLayoutTestMeasurementPassed := false
}
adaptiveLayoutTestDetails .=
adaptiveLayoutTestCommandIndex
. "번 측정: "
. adaptiveLayoutTestDrawCommand.MeasuredWidth
. " × "
. adaptiveLayoutTestDrawCommand.MeasuredHeight
. "px"
. "`n"
}
adaptiveLayoutTestDetails .= "`n"
;------------------------------------------------------------
; 메시지 사이 실제 간격 검사
;------------------------------------------------------------
adaptiveLayoutTestPairCount :=
adaptiveLayoutTestDrawCommands.Length - 1
Loop adaptiveLayoutTestPairCount
{
adaptiveLayoutTestCurrentCommand :=
adaptiveLayoutTestDrawCommands[A_Index]
adaptiveLayoutTestNextCommand :=
adaptiveLayoutTestDrawCommands[A_Index + 1]
adaptiveLayoutTestCurrentBottom :=
adaptiveLayoutTestLayout.CalculateVisualBottom(
adaptiveLayoutTestCurrentCommand
)
adaptiveLayoutTestNextTop :=
adaptiveLayoutTestLayout.CalculateVisualTop(
adaptiveLayoutTestNextCommand
)
adaptiveLayoutTestGap :=
adaptiveLayoutTestNextTop
- adaptiveLayoutTestCurrentBottom
if (
adaptiveLayoutTestGap
< adaptiveLayoutTestLayout.LineGap
)
{
adaptiveLayoutTestNoOverlapPassed := false
}
adaptiveLayoutTestDetails .=
A_Index
. " → "
. (A_Index + 1)
. " 실제 간격: "
. adaptiveLayoutTestGap
. "px"
. "`n"
}
;------------------------------------------------------------
; 스타일에 따라 줄 높이가 달라지는지 검사
;------------------------------------------------------------
adaptiveLayoutTestDonationAdvance :=
adaptiveLayoutTestLayout.CalculateLineAdvance(
adaptiveLayoutTestDrawCommands[4]
)
adaptiveLayoutTestSystemAdvance :=
adaptiveLayoutTestLayout.CalculateLineAdvance(
adaptiveLayoutTestDrawCommands[6]
)
adaptiveLayoutTestDynamicHeightPassed :=
adaptiveLayoutTestDonationAdvance
> adaptiveLayoutTestSystemAdvance
;------------------------------------------------------------
; 최종 판정
;------------------------------------------------------------
adaptiveLayoutTestPassed :=
adaptiveLayoutTestMeasurementPassed
&& adaptiveLayoutTestNoOverlapPassed
&& adaptiveLayoutTestDynamicHeightPassed
adaptiveLayoutTestResultText := ""
adaptiveLayoutTestResultText .=
"화면 DPI: "
. A_ScreenDPI
. "`n"
adaptiveLayoutTestResultText .=
"후원 줄 이동량: "
. adaptiveLayoutTestDonationAdvance
. "px"
. "`n"
adaptiveLayoutTestResultText .=
"시스템 줄 이동량: "
. adaptiveLayoutTestSystemAdvance
. "px"
. "`n`n"
adaptiveLayoutTestResultText .=
adaptiveLayoutTestDetails
. "`n"
adaptiveLayoutTestResultText .=
"실제 GDI 측정값 생성: "
. (
adaptiveLayoutTestMeasurementPassed
? "성공"
: "실패"
)
. "`n"
adaptiveLayoutTestResultText .=
"메시지 겹침 검사: "
. (
adaptiveLayoutTestNoOverlapPassed
? "성공"
: "실패"
)
. "`n"
adaptiveLayoutTestResultText .=
"스타일별 높이 변화: "
. (
adaptiveLayoutTestDynamicHeightPassed
? "성공"
: "실패"
)
. "`n`n"
adaptiveLayoutTestResultText .=
(
adaptiveLayoutTestPassed
? "실측 TextLayout 테스트 성공"
: "실측 TextLayout 테스트 실패"
)
MsgBox(
adaptiveLayoutTestResultText,
"Build 1.7.2 - GDI Measured TextLayout Test"
)
🔴 Tests/AutoWrapOverlayTest.ahk — 자동 줄바꿈 화면 테스트 (긴 URL 분할 포함) #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Services\MessagePipeline.ahk
#Include ..\Engine\Rendering\LayeredOverlayRenderer.ahk
;============================================================
; Build 2.0 - Auto Wrap Test
;
; - 최대 텍스트 폭: 760px
; - 여러 줄 높이 자동 반영
; - 긴 URL 같은 공백 없는 문자열도 분할
; - Layered Alpha / Glow 유지
;
; 종료: Esc
;============================================================
autoWrapStylePath :=
A_ScriptDir
. "\..\Data\Settings\Styles.ini"
autoWrapStyleLoader :=
StyleLoader()
autoWrapStyleConfiguration :=
autoWrapStyleLoader.Load(
autoWrapStylePath
)
autoWrapStyleResolver :=
StyleResolver(
autoWrapStyleConfiguration
)
autoWrapMessageBuffer :=
MessageBuffer(20)
autoWrapTextMeasurer :=
GdiTextMeasurer()
autoWrapTextLayout :=
TextLayout(
50, ; 왼쪽 여백
35, ; 위쪽 여백
40, ; 최소 메시지 높이
14, ; 메시지 사이 간격
autoWrapTextMeasurer,
760, ; 최대 텍스트 폭
5 ; 한 메시지 내부 줄 간격
)
autoWrapMessagePipeline :=
MessagePipeline(
autoWrapMessageBuffer,
autoWrapStyleResolver,
autoWrapTextLayout
)
autoWrapMessageBuffer.Add(
ChatMessage(
"일반 사용자",
"이 문장은 오버레이의 최대 텍스트 폭을 넘어가면 자동으로 다음 줄로 이동해야 합니다. 다음 메시지는 이 여러 줄 메시지의 실제 높이 아래에 배치되어야 합니다.",
"Normal"
)
)
autoWrapMessageBuffer.Add(
ChatMessage(
"관리자",
"관리자 메시지도 같은 폭에서 자동 줄바꿈되며 Glow와 외곽선은 모든 줄에 동일하게 적용됩니다.",
"Admin"
)
)
autoWrapMessageBuffer.Add(
ChatMessage(
"후원자",
"후원 메시지 긴 문장 테스트입니다. 한 메시지가 두 줄이나 세 줄이 되어도 아래 공지 메시지와 겹치면 안 됩니다.",
"Donation"
)
)
autoWrapMessageBuffer.Add(
ChatMessage(
"긴문자열",
"https://example.com/ThisIsAVeryLongTextWithoutSpaces1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ가로폭을넘으면글자단위로분할됩니다",
"Notice"
)
)
autoWrapMessageBuffer.Add(
ChatMessage(
"VIP",
"마지막 메시지가 앞의 여러 줄 메시지 아래에 정상적으로 표시되면 성공입니다.",
"VIP"
)
)
autoWrapDrawCommands :=
autoWrapMessagePipeline.BuildDrawCommands()
autoWrapWindow :=
Gui(
"+AlwaysOnTop -Caption +ToolWindow +E0x80000 +E0x20",
"OBSLiveChat Build 2.0 Auto Wrap"
)
autoWrapWindow.Show(
"x20 y20 w1100 h900 NA"
)
autoWrapRenderer :=
LayeredOverlayRenderer(
autoWrapWindow.Hwnd
)
autoWrapRenderer.SetDrawCommands(
autoWrapDrawCommands
)
ToolTip(
"Build 2.0 자동 줄바꿈 테스트`n"
. "오른쪽 잘림과 줄 겹침이 없어야 합니다.`n"
. "종료: Esc",
20,
930
)
SetTimer(
() => ToolTip(),
-4500
)
Esc::
{
autoWrapRenderer.Shutdown()
ExitApp()
}
🔴 Tests/EnterModeValidationTest.ahk — Enter 전송 ON/OFF + 입력 검사 테스트 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Services\LiveOverlayController.ahk
#Include ..\Engine\Validation\MessageTextValidator.ahk
#Include ..\Engine\Rendering\LayeredOverlayRenderer.ahk
;============================================================
; Build 2.3 - Enter 전송 ON/OFF + 입력 검사
;
; Enter 전송 ON:
; - 일반 Enter 또는 넘버패드 Enter 즉시 송출
;
; Enter 전송 OFF:
; - Enter를 누르면 입력 검사만 실행
; - 검사 통과 후 송출 버튼 활성화
; - 내용을 수정하면 다시 검사 필요
;
; Esc:
; - 종료
;============================================================
enterModeStylePath :=
A_ScriptDir
. "\..\Data\Settings\Styles.ini"
enterModeStyleLoader :=
StyleLoader()
enterModeStyleConfiguration :=
enterModeStyleLoader.Load(
enterModeStylePath
)
enterModeStyleResolver :=
StyleResolver(
enterModeStyleConfiguration
)
enterModeTextMeasurer :=
GdiTextMeasurer()
enterModeTextLayout :=
TextLayout(
45,
25,
40,
13,
enterModeTextMeasurer,
720,
5
)
enterModeOverlayWidth := 900
enterModeOverlayHeight := 720
enterModeViewportManager :=
ViewportLayout(
enterModeTextLayout,
enterModeOverlayHeight,
25,
30
)
enterModeBroadcastWindow :=
Gui(
"+AlwaysOnTop -Caption +ToolWindow +E0x80000 +E0x20",
"OBSLiveChat 송출 오버레이"
)
enterModeBroadcastWindow.Show(
"x20 y20 w"
. enterModeOverlayWidth
. " h"
. enterModeOverlayHeight
. " NA"
)
enterModeRenderer :=
LayeredOverlayRenderer(
enterModeBroadcastWindow.Hwnd
)
enterModeController :=
LiveOverlayController(
enterModeStyleResolver,
enterModeTextLayout,
enterModeViewportManager,
enterModeRenderer,
100
)
messageValidatorInstance :=
MessageTextValidator(
300
)
enterModeTypeMap :=
Map(
"일반", "Normal",
"관리자", "Admin",
"멤버", "Member",
"후원", "Donation",
"공지", "Notice",
"시스템", "System",
"VIP", "VIP"
)
messageValidationPassed := false
enterModeAdminWindow :=
Gui(
"+AlwaysOnTop",
"OBSLiveChat 관리자 입력 모드 테스트"
)
enterModeAdminWindow.SetFont(
"s10",
"맑은 고딕"
)
enterModeAdminWindow.AddText(
"x20 y22 w60 h24",
"작성자"
)
enterModeAuthorInput :=
enterModeAdminWindow.AddEdit(
"x90 y18 w220 h26",
"관리자"
)
enterModeAdminWindow.AddText(
"x335 y22 w80 h24",
"메시지 종류"
)
enterModeTypeDropDown :=
enterModeAdminWindow.AddDropDownList(
"x425 y18 w160 Choose2",
[
"일반",
"관리자",
"멤버",
"후원",
"공지",
"시스템",
"VIP"
]
)
enterSendCheckBox :=
enterModeAdminWindow.AddCheckBox(
"x620 y20 w160 h24 Checked",
"Enter 전송 ON"
)
enterModeAdminWindow.AddText(
"x20 y68 w60 h24",
"메시지"
)
enterModeMessageInput :=
enterModeAdminWindow.AddEdit(
"x90 y63 w650 h30"
)
validateMessageButton :=
enterModeAdminWindow.AddButton(
"x755 y62 w100 h32",
"입력 검사"
)
sendMessageButton :=
enterModeAdminWindow.AddButton(
"x755 y104 w100 h32",
"송출"
)
clearOverlayButton :=
enterModeAdminWindow.AddButton(
"x645 y104 w100 h32",
"전체 지우기"
)
enterModeStatusText :=
enterModeAdminWindow.AddText(
"x90 y109 w535 h48",
"Enter 전송 ON: 일반 Enter와 넘버패드 Enter로 즉시 송출"
)
enterModeAuthorInput.OnEvent(
"Change",
InvalidateCurrentValidation
)
enterModeTypeDropDown.OnEvent(
"Change",
InvalidateCurrentValidation
)
enterModeMessageInput.OnEvent(
"Change",
InvalidateCurrentValidation
)
enterSendCheckBox.OnEvent(
"Click",
HandleEnterSendModeChanged
)
validateMessageButton.OnEvent(
"Click",
ValidateCurrentMessage
)
sendMessageButton.OnEvent(
"Click",
SubmitCurrentMessage
)
clearOverlayButton.OnEvent(
"Click",
ClearCurrentOverlay
)
enterModeAdminWindow.OnEvent(
"Close",
ExitEnterModeTest
)
enterModeAdminWindow.Show(
"x950 y40 w880 h175"
)
UpdateEnterSendModeUi()
enterModeMessageInput.Focus()
enterModeController.AddMessage(
"시스템",
"Enter 전송 ON/OFF와 입력 검사 기능을 테스트하세요.",
"System"
)
HandleEnterSendModeChanged(*)
{
InvalidateCurrentValidation()
UpdateEnterSendModeUi()
}
UpdateEnterSendModeUi()
{
global enterSendCheckBox
global sendMessageButton
global enterModeStatusText
global messageValidationPassed
if (enterSendCheckBox.Value)
{
enterSendCheckBox.Text :=
"Enter 전송 ON"
sendMessageButton.Enabled :=
true
enterModeStatusText.Value :=
"Enter 전송 ON: Enter 또는 넘버패드 Enter로 즉시 송출"
return
}
enterSendCheckBox.Text :=
"Enter 전송 OFF"
sendMessageButton.Enabled :=
messageValidationPassed
enterModeStatusText.Value :=
"Enter 전송 OFF: Enter는 검사만 실행 / 검사 통과 후 송출 버튼"
}
InvalidateCurrentValidation(*)
{
global messageValidationPassed
global enterSendCheckBox
global sendMessageButton
global enterModeStatusText
messageValidationPassed := false
if (!enterSendCheckBox.Value)
{
sendMessageButton.Enabled := false
enterModeStatusText.Value :=
"내용이 변경되었습니다. 입력 검사를 다시 실행하세요."
}
}
ValidateCurrentMessage(*)
{
global enterModeAuthorInput
global enterModeMessageInput
global enterModeStatusText
global sendMessageButton
global messageValidatorInstance
global messageValidationPassed
global enterSendCheckBox
authorText :=
Trim(
enterModeAuthorInput.Value
)
if (authorText = "")
{
messageValidationPassed := false
sendMessageButton.Enabled :=
enterSendCheckBox.Value
enterModeStatusText.Value :=
"작성자를 입력하세요."
enterModeAuthorInput.Focus()
return false
}
validationResult :=
messageValidatorInstance.Validate(
enterModeMessageInput.Value
)
if (!validationResult["IsValid"])
{
messageValidationPassed := false
sendMessageButton.Enabled :=
enterSendCheckBox.Value
enterModeStatusText.Value :=
"검사 실패: "
. validationResult["Message"]
enterModeMessageInput.Focus()
return false
}
enterModeMessageInput.Value :=
validationResult[
"NormalizedText"
]
messageValidationPassed := true
sendMessageButton.Enabled := true
enterModeStatusText.Value :=
validationResult[
"Message"
]
enterModeMessageInput.Focus()
return true
}
SubmitCurrentMessage(*)
{
global enterModeAuthorInput
global enterModeMessageInput
global enterModeTypeDropDown
global enterModeStatusText
global enterModeTypeMap
global enterModeController
global enterSendCheckBox
global messageValidationPassed
global sendMessageButton
if (
!enterSendCheckBox.Value
&& !messageValidationPassed
)
{
enterModeStatusText.Value :=
"Enter 전송 OFF입니다. 먼저 입력 검사를 통과하세요."
enterModeMessageInput.Focus()
return
}
authorText :=
Trim(
enterModeAuthorInput.Value
)
messageText :=
Trim(
enterModeMessageInput.Value
)
if (authorText = "")
{
enterModeStatusText.Value :=
"작성자를 입력하세요."
enterModeAuthorInput.Focus()
return
}
if (messageText = "")
{
enterModeStatusText.Value :=
"메시지를 입력하세요."
enterModeMessageInput.Focus()
return
}
selectedTypeLabel :=
enterModeTypeDropDown.Text
if (!enterModeTypeMap.Has(selectedTypeLabel))
{
enterModeStatusText.Value :=
"메시지 종류를 선택하세요."
return
}
selectedMessageType :=
enterModeTypeMap[
selectedTypeLabel
]
visibleCount :=
enterModeController.AddMessage(
authorText,
messageText,
selectedMessageType
)
enterModeMessageInput.Value := ""
messageValidationPassed := false
if (enterSendCheckBox.Value)
{
sendMessageButton.Enabled := true
}
else
{
sendMessageButton.Enabled := false
}
enterModeStatusText.Value :=
"송출 완료 / 현재 화면 메시지 "
. visibleCount
. "개"
enterModeMessageInput.Focus()
}
HandleEnterKey()
{
global enterSendCheckBox
if (enterSendCheckBox.Value)
{
SubmitCurrentMessage()
return
}
ValidateCurrentMessage()
}
ClearCurrentOverlay(*)
{
global enterModeController
global enterModeStatusText
global enterModeMessageInput
enterModeController.ClearMessages()
enterModeStatusText.Value :=
"오버레이 메시지를 모두 지웠습니다."
enterModeMessageInput.Focus()
}
ExitEnterModeTest(*)
{
global enterModeRenderer
enterModeRenderer.Shutdown()
ExitApp()
}
#HotIf WinActive("OBSLiveChat 관리자 입력 모드 테스트")
Enter::
{
HandleEnterKey()
}
NumpadEnter::
{
HandleEnterKey()
}
#HotIf
Esc::
{
ExitEnterModeTest()
}
🔴 Tests/ExtendedStyleLoaderTest.ahk — 스타일 상속·덮어쓰기·확장 속성 로딩 검사 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Model\ChatMessage.ahk
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Pipeline\StyleResolver.ahk
;============================================================
; ExtendedStyleLoaderTest
;
; Defaults 상속, 스타일별 덮어쓰기,
; ValueParser 연동 및 TextStyle 생성을 검사한다.
;============================================================
extendedLoaderTestFilePath :=
A_ScriptDir "\..\Data\Settings\Styles.ini"
extendedLoaderTestInstance :=
StyleLoader()
extendedLoaderTestConfiguration :=
extendedLoaderTestInstance.Load(
extendedLoaderTestFilePath
)
extendedLoaderTestResolver :=
StyleResolver(
extendedLoaderTestConfiguration
)
extendedLoaderTestNormalMessage := ChatMessage(
"일반 사용자",
"일반 메시지",
"Normal"
)
extendedLoaderTestAdminMessage := ChatMessage(
"관리자",
"관리자 메시지",
"Admin"
)
extendedLoaderTestDonationMessage := ChatMessage(
"후원자",
"후원 메시지",
"Donation"
)
extendedLoaderTestSystemMessage := ChatMessage(
"시스템",
"시스템 메시지",
"System"
)
extendedLoaderTestNormalStyle :=
extendedLoaderTestResolver.Resolve(
extendedLoaderTestNormalMessage
)
extendedLoaderTestAdminStyle :=
extendedLoaderTestResolver.Resolve(
extendedLoaderTestAdminMessage
)
extendedLoaderTestDonationStyle :=
extendedLoaderTestResolver.Resolve(
extendedLoaderTestDonationMessage
)
extendedLoaderTestSystemStyle :=
extendedLoaderTestResolver.Resolve(
extendedLoaderTestSystemMessage
)
extendedLoaderTestNormalPassed :=
(
extendedLoaderTestNormalStyle.FontName = "맑은 고딕"
&& extendedLoaderTestNormalStyle.FontSize = 22
&& extendedLoaderTestNormalStyle.Color = "FFFFFF"
&& extendedLoaderTestNormalStyle.ShadowEnabled
&& extendedLoaderTestNormalStyle.OutlineEnabled
&& !extendedLoaderTestNormalStyle.BackgroundEnabled
)
extendedLoaderTestAdminPassed :=
(
extendedLoaderTestAdminStyle.Color = "FFD700"
&& extendedLoaderTestAdminStyle.FontWeight = 700
&& extendedLoaderTestAdminStyle.OutlineSize = 2
&& extendedLoaderTestAdminStyle.ShadowOffsetX = 2
)
extendedLoaderTestDonationPassed :=
(
extendedLoaderTestDonationStyle.Color = "00BFFF"
&& extendedLoaderTestDonationStyle.FontSize = 24
&& extendedLoaderTestDonationStyle.BackgroundEnabled
&& extendedLoaderTestDonationStyle.BackgroundColor = "001520"
&& extendedLoaderTestDonationStyle.BackgroundOpacity = 180
&& extendedLoaderTestDonationStyle.PaddingX = 10
&& extendedLoaderTestDonationStyle.PaddingY = 6
)
extendedLoaderTestSystemPassed :=
(
extendedLoaderTestSystemStyle.Color = "C0C0C0"
&& extendedLoaderTestSystemStyle.FontSize = 20
&& extendedLoaderTestSystemStyle.Opacity = 220
)
extendedLoaderTestPassed :=
(
extendedLoaderTestNormalPassed
&& extendedLoaderTestAdminPassed
&& extendedLoaderTestDonationPassed
&& extendedLoaderTestSystemPassed
)
extendedLoaderTestResultText := ""
extendedLoaderTestResultText .=
"불러온 스타일 수: "
. extendedLoaderTestResolver.GetStyleCount()
. "`n`n"
extendedLoaderTestResultText .=
"Normal 기본값 상속: "
. (
extendedLoaderTestNormalPassed
? "성공"
: "실패"
)
. "`n"
extendedLoaderTestResultText .=
"Admin 부분 덮어쓰기: "
. (
extendedLoaderTestAdminPassed
? "성공"
: "실패"
)
. "`n"
extendedLoaderTestResultText .=
"Donation 배경 스타일: "
. (
extendedLoaderTestDonationPassed
? "성공"
: "실패"
)
. "`n"
extendedLoaderTestResultText .=
"System 크기·투명도: "
. (
extendedLoaderTestSystemPassed
? "성공"
: "실패"
)
. "`n`n"
extendedLoaderTestResultText .=
(
extendedLoaderTestPassed
? "확장 StyleLoader 테스트 성공"
: "확장 StyleLoader 테스트 실패"
)
MsgBox(
extendedLoaderTestResultText,
"Build 1.2.4 - Extended StyleLoader Test"
)
🔴 Tests/ExtendedTextStyleTest.ahk — 확장 TextStyle 생성·속성 검증 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Rendering\TextStyle.ahk
;============================================================
; ExtendedTextStyleTest
;
; 확장된 TextStyle의 생성과 각 속성 검증을 테스트한다.
;============================================================
extendedStyleTestInstance := TextStyle(
"FFD700",
"맑은 고딕",
24,
700,
240,
true,
"000000",
2,
3,
true,
"101010",
2,
true,
"201000",
180,
10,
6
)
extendedStyleTestValuesPassed :=
(
extendedStyleTestInstance.Color = "FFD700"
&& extendedStyleTestInstance.FontName = "맑은 고딕"
&& extendedStyleTestInstance.FontSize = 24
&& extendedStyleTestInstance.FontWeight = 700
&& extendedStyleTestInstance.Opacity = 240
&& extendedStyleTestInstance.ShadowEnabled
&& extendedStyleTestInstance.ShadowColor = "000000"
&& extendedStyleTestInstance.ShadowOffsetX = 2
&& extendedStyleTestInstance.ShadowOffsetY = 3
&& extendedStyleTestInstance.OutlineEnabled
&& extendedStyleTestInstance.OutlineColor = "101010"
&& extendedStyleTestInstance.OutlineSize = 2
&& extendedStyleTestInstance.BackgroundEnabled
&& extendedStyleTestInstance.BackgroundColor = "201000"
&& extendedStyleTestInstance.BackgroundOpacity = 180
&& extendedStyleTestInstance.PaddingX = 10
&& extendedStyleTestInstance.PaddingY = 6
)
extendedStyleTestInvalidOpacityRejected := false
try
{
invalidOpacityStyleInstance := TextStyle(
"FFFFFF",
"맑은 고딕",
22,
400,
300
)
}
catch ValueError
{
extendedStyleTestInvalidOpacityRejected := true
}
extendedStyleTestInvalidOutlineRejected := false
try
{
invalidOutlineStyleInstance := TextStyle(
"FFFFFF",
"맑은 고딕",
22,
400,
255,
true,
"000000",
2,
2,
true,
"000000",
-1
)
}
catch ValueError
{
extendedStyleTestInvalidOutlineRejected := true
}
extendedStyleTestInvalidColorRejected := false
try
{
invalidColorStyleInstance := TextStyle(
"YELLOW"
)
}
catch ValueError
{
extendedStyleTestInvalidColorRejected := true
}
extendedStyleTestPassed :=
(
extendedStyleTestValuesPassed
&& extendedStyleTestInvalidOpacityRejected
&& extendedStyleTestInvalidOutlineRejected
&& extendedStyleTestInvalidColorRejected
)
extendedStyleTestResultText :=
(
extendedStyleTestInstance.ToDebugText()
. "`n`n"
. "전체 속성 생성: "
. (
extendedStyleTestValuesPassed
? "성공"
: "실패"
)
. "`n"
. "잘못된 투명도 거부: "
. (
extendedStyleTestInvalidOpacityRejected
? "성공"
: "실패"
)
. "`n"
. "음수 외곽선 거부: "
. (
extendedStyleTestInvalidOutlineRejected
? "성공"
: "실패"
)
. "`n"
. "잘못된 색상 거부: "
. (
extendedStyleTestInvalidColorRejected
? "성공"
: "실패"
)
. "`n`n"
. (
extendedStyleTestPassed
? "확장 TextStyle 테스트 성공"
: "확장 TextStyle 테스트 실패"
)
)
MsgBox(
extendedStyleTestResultText,
"Build 1.2.1 - Extended TextStyle Test"
)
🔴 Tests/LayeredGlowOverlayTest.ahk — 픽셀별 알파 글로우 화면 테스트 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Services\MessagePipeline.ahk
#Include ..\Engine\Rendering\LayeredOverlayRenderer.ahk
;============================================================
; Build 1.9.1 - Layered Glow Overlay Test
;
; WinSetTransColor 사용 안 함
; UpdateLayeredWindow + 픽셀별 Alpha Glow
;
; 종료: Esc
;============================================================
layeredGlowStylePath :=
A_ScriptDir "\..\Data\Settings\Styles.ini"
layeredGlowStyleLoader :=
StyleLoader()
layeredGlowStyleConfiguration :=
layeredGlowStyleLoader.Load(
layeredGlowStylePath
)
layeredGlowStyleResolver :=
StyleResolver(
layeredGlowStyleConfiguration
)
layeredGlowMessageBuffer :=
MessageBuffer(20)
layeredGlowTextMeasurer :=
GdiTextMeasurer()
layeredGlowTextLayout :=
TextLayout(
50,
35,
40,
12,
layeredGlowTextMeasurer
)
layeredGlowMessagePipeline :=
MessagePipeline(
layeredGlowMessageBuffer,
layeredGlowStyleResolver,
layeredGlowTextLayout
)
layeredGlowMessageBuffer.Add(
ChatMessage(
"일반",
"Glow 없음",
"Normal"
)
)
layeredGlowMessageBuffer.Add(
ChatMessage(
"관리자",
"금색 Glow",
"Admin"
)
)
layeredGlowMessageBuffer.Add(
ChatMessage(
"멤버",
"Glow 없음",
"Member"
)
)
layeredGlowMessageBuffer.Add(
ChatMessage(
"후원",
"하늘색 Glow",
"Donation"
)
)
layeredGlowMessageBuffer.Add(
ChatMessage(
"공지",
"주황색 Glow",
"Notice"
)
)
layeredGlowMessageBuffer.Add(
ChatMessage(
"시스템",
"Opacity 220",
"System"
)
)
layeredGlowMessageBuffer.Add(
ChatMessage(
"VIP",
"금색 본문 + White Glow",
"VIP"
)
)
layeredGlowDrawCommands :=
layeredGlowMessagePipeline.BuildDrawCommands()
layeredGlowWindow :=
Gui(
"+AlwaysOnTop -Caption +ToolWindow +E0x80000 +E0x20",
"OBSLiveChat Layered Glow Test"
)
layeredGlowWindow.Show(
"x20 y20 w1200 h720 NA"
)
layeredGlowRenderer :=
LayeredOverlayRenderer(
layeredGlowWindow.Hwnd
)
layeredGlowRenderer.SetDrawCommands(
layeredGlowDrawCommands
)
ToolTip(
"Build 1.9.1 Layered Glow 테스트`n"
. "검은 사각형과 색상 왜곡이 없어야 합니다.`n"
. "종료: Esc",
20,
750
)
SetTimer(
() => ToolTip(),
-4000
)
Esc::
{
layeredGlowRenderer.Shutdown()
ExitApp()
}
🔴 Tests/LayeredOverlayTest.ahk — 레이어드 알파 기반 화면 테스트 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Services\MessagePipeline.ahk
#Include ..\Engine\Rendering\LayeredOverlayRenderer.ahk
;============================================================
; Build 1.9 - Layered Alpha Foundation Test
;
; 색상 키와 WinSetTransColor를 사용하지 않는다.
; UpdateLayeredWindow의 픽셀별 Alpha만 테스트한다.
;
; 종료: Esc
;============================================================
layeredTestStylePath :=
A_ScriptDir "\..\Data\Settings\Styles.ini"
layeredTestStyleLoader :=
StyleLoader()
layeredTestStyleConfiguration :=
layeredTestStyleLoader.Load(
layeredTestStylePath
)
layeredTestStyleResolver :=
StyleResolver(
layeredTestStyleConfiguration
)
layeredTestMessageBuffer :=
MessageBuffer(20)
layeredTestTextMeasurer :=
GdiTextMeasurer()
layeredTestTextLayout :=
TextLayout(
40,
35,
40,
12,
layeredTestTextMeasurer
)
layeredTestMessagePipeline :=
MessagePipeline(
layeredTestMessageBuffer,
layeredTestStyleResolver,
layeredTestTextLayout
)
layeredTestMessageBuffer.Add(
ChatMessage(
"일반 사용자",
"Layered Alpha 일반 메시지입니다.",
"Normal"
)
)
layeredTestMessageBuffer.Add(
ChatMessage(
"관리자",
"관리자 메시지입니다.",
"Admin"
)
)
layeredTestMessageBuffer.Add(
ChatMessage(
"멤버",
"멤버 메시지입니다.",
"Member"
)
)
layeredTestMessageBuffer.Add(
ChatMessage(
"후원자",
"후원 메시지입니다.",
"Donation"
)
)
layeredTestMessageBuffer.Add(
ChatMessage(
"공지",
"공지 메시지입니다.",
"Notice"
)
)
layeredTestMessageBuffer.Add(
ChatMessage(
"시스템",
"Opacity 220 시스템 메시지입니다.",
"System"
)
)
layeredTestMessageBuffer.Add(
ChatMessage(
"VIP",
"VIP 메시지입니다.",
"VIP"
)
)
layeredTestDrawCommands :=
layeredTestMessagePipeline.BuildDrawCommands()
;------------------------------------------------------------
; +E0x80000 : WS_EX_LAYERED
; +E0x20 : 클릭 통과
;------------------------------------------------------------
layeredTestWindow :=
Gui(
"+AlwaysOnTop -Caption +ToolWindow +E0x80000 +E0x20",
"OBSLiveChat Layered Alpha Test"
)
layeredTestWindow.Show(
"x20 y20 w1400 h760 NA"
)
layeredTestRenderer :=
LayeredOverlayRenderer(
layeredTestWindow.Hwnd
)
layeredTestRenderer.SetDrawCommands(
layeredTestDrawCommands
)
ToolTip(
"Build 1.9 Layered Alpha 테스트`n"
. "검은 사각형이 없어야 합니다.`n"
. "종료: Esc",
20,
790
)
SetTimer(
() => ToolTip(),
-4000
)
Esc::
{
layeredTestRenderer.Shutdown()
ExitApp()
}
🔴 Tests/LiveChatFetchTest.ahk — YoutubeProvider 단독 동작 확인 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Include ..\Engine\Providers\YoutubeProvider.ahk
; ============================================================
; LiveChatFetchTest
;
; 목적:
; - 전체 프로그램(관리자 창, 오버레이 등)과 완전히 무관하게
; YoutubeProvider 하나만 떼어서 검증한다.
; - API 키와 라이브 URL을 입력하면 실제 채팅이 이 창에 그대로 찍힌다.
; - 여기서 문제가 없어야 다음 단계(전체 프로그램 연결)로 넘어간다.
; ============================================================
testWindow := Gui("+Resize", "라이브 채팅 연동 테스트")
testWindow.SetFont("s10", "맑은 고딕")
testWindow.AddText("x20 y20 w120", "유튜브 API 키")
apiKeyInput := testWindow.AddEdit("x150 y17 w500")
testWindow.AddText("x20 y55 w120", "라이브 URL/ID")
liveUrlInput := testWindow.AddEdit("x150 y52 w500")
connectButton := testWindow.AddButton("x150 y85 w120 h32", "연결 시작")
disconnectButton := testWindow.AddButton("x280 y85 w120 h32", "폴링 중지")
disconnectButton.Enabled := false
statusText := testWindow.AddText("x20 y130 w630", "대기 중...")
logBox := testWindow.AddEdit(
"x20 y155 w630 h350 +ReadOnly +Multi +VScroll"
)
testWindow.Show("w670 h530")
providerInstance := 0
pollingTimerBound := 0
connectButton.OnEvent("Click", StartConnection)
disconnectButton.OnEvent("Click", StopPolling)
testWindow.OnEvent("Close", (*) => ExitApp())
StartConnection(*)
{
global providerInstance
global pollingTimerBound
apiKeyText := Trim(apiKeyInput.Value)
liveUrlText := Trim(liveUrlInput.Value)
if (apiKeyText = "" || liveUrlText = "")
{
statusText.Value := "API 키와 URL을 모두 입력하세요."
return
}
statusText.Value := "연결 중..."
connectButton.Enabled := false
try
{
providerInstance := YoutubeProvider(apiKeyText)
videoId :=
providerInstance.ExtractVideoId(
liveUrlText
)
connectResult :=
providerInstance.ResolveLiveChatId(
videoId
)
statusText.Value :=
"연결됨: "
. connectResult["Title"]
AppendLog(
"=== 연결 성공 ==="
)
AppendLog(
"영상 제목: "
. connectResult["Title"]
)
AppendLog(
"liveChatId: "
. connectResult["LiveChatId"]
)
disconnectButton.Enabled := true
pollingTimerBound :=
ObjBindMethod(
RunPoll
)
SetTimer(
pollingTimerBound,
-100
)
}
catch as errorObject
{
statusText.Value :=
"연결 실패"
AppendLog(
"=== 연결 실패 ===`n"
. errorObject.Message
)
connectButton.Enabled := true
}
}
RunPoll()
{
global providerInstance
global pollingTimerBound
if (!IsObject(providerInstance))
{
return
}
try
{
pollResult :=
providerInstance.Poll()
for chatMessage in pollResult["Messages"]
{
AppendLog(
"["
. chatMessage["Type"]
. "] "
. chatMessage["Author"]
. ": "
. chatMessage["Message"]
)
}
nextIntervalMs :=
pollResult["PollingIntervalMillis"]
; 너무 잦은 요청으로 할당량을 낭비하지 않도록
; 최소 2000ms는 보장한다.
if (nextIntervalMs < 2000)
{
nextIntervalMs := 2000
}
SetTimer(
pollingTimerBound,
-nextIntervalMs
)
}
catch as errorObject
{
AppendLog(
"=== 폴링 오류 ===`n"
. errorObject.Message
)
SetTimer(
pollingTimerBound,
-5000
)
}
}
StopPolling(*)
{
global providerInstance
global pollingTimerBound
if (IsObject(pollingTimerBound))
{
SetTimer(
pollingTimerBound,
0
)
}
providerInstance := 0
connectButton.Enabled := true
disconnectButton.Enabled := false
statusText.Value := "폴링 중지됨"
AppendLog(
"=== 폴링 중지 ==="
)
}
AppendLog(lineText)
{
logBox.Value :=
logBox.Value
. lineText
. "`r`n"
}
🔴 Tests/MacroAdminOverlayTest.ahk — 관리자 창 통합 테스트 (매크로·카테고리·표시 개수) #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Configuration\OverlaySettingsLoader.ahk
#Include ..\Engine\Services\LiveOverlayController.ahk
#Include ..\Engine\Validation\MessageTextValidator.ahk
#Include ..\Engine\Services\ChatMacroRepository.ahk
#Include ..\Engine\Pipeline\VisibleMessageWindowSizer.ahk
#Include ..\Engine\Rendering\LayeredOverlayRenderer.ahk
;============================================================
; Build 2.7 - 관리자 창 표시 개수 통합
;
; 기능:
; - 카테고리 필터
; - 매크로 등록
; - 매크로 삭제
; - 카테고리 삭제
; - 목록 한 번 클릭 송출 ON/OFF
; - 선택 송출 버튼
; - Enter 전송 ON/OFF
; - 입력 검사
; - 표시 개수 숫자 입력 박스
; - 투명창 위쪽 고정 / 아래쪽만 크기 변경
; - 표시 개수 설정 Overlay.ini 저장
;============================================================
macroStylePath :=
A_ScriptDir
. "\..\Data\Settings\Styles_OBS20.ini"
macroOverlaySettingsPath :=
A_ScriptDir
. "\..\Data\Settings\Overlay.ini"
macroStoragePath :=
A_ScriptDir
. "\..\Data\Macros\ChatMacros.tsv"
macroStyleLoaderInstance :=
StyleLoader()
macroStyleConfiguration :=
macroStyleLoaderInstance.Load(
macroStylePath
)
macroStyleResolverInstance :=
StyleResolver(
macroStyleConfiguration
)
macroOverlaySettingsLoader :=
OverlaySettingsLoader()
macroOverlaySettings :=
macroOverlaySettingsLoader.Load(
macroOverlaySettingsPath
)
macroTextMeasurerInstance :=
GdiTextMeasurer()
macroTextLayoutInstance :=
TextLayout(
macroOverlaySettings[
"LeftMargin"
],
macroOverlaySettings[
"TopMargin"
],
macroOverlaySettings[
"MinimumMessageHeight"
],
macroOverlaySettings[
"MessageGap"
],
macroTextMeasurerInstance,
macroOverlaySettings[
"MaximumTextWidth"
],
macroOverlaySettings[
"TextLineGap"
]
)
macroVisibleWindowSizer :=
VisibleMessageWindowSizer(
macroTextLayoutInstance,
macroOverlaySettings[
"TopMargin"
],
macroOverlaySettings[
"BottomMargin"
]
)
macroCurrentVisibleLimit :=
macroOverlaySettings[
"VisibleMessageLimit"
]
macroOverlayWidth :=
macroOverlaySettings[
"Width"
]
macroOverlayHeight :=
macroVisibleWindowSizer.CalculateHeight(
macroCurrentVisibleLimit,
macroStyleConfiguration[
"Styles"
]
)
macroViewportManager :=
ViewportLayout(
macroTextLayoutInstance,
macroOverlayHeight,
macroOverlaySettings[
"TopMargin"
],
macroOverlaySettings[
"BottomMargin"
]
)
macroBroadcastWindowOptions :=
"-Caption +ToolWindow +E0x80000"
if (
macroOverlaySettings[
"AlwaysOnTop"
]
)
{
macroBroadcastWindowOptions .=
" +AlwaysOnTop"
}
if (
macroOverlaySettings[
"ClickThrough"
]
)
{
macroBroadcastWindowOptions .=
" +E0x20"
}
macroBroadcastWindow :=
Gui(
macroBroadcastWindowOptions,
"OBSLiveChat 송출 오버레이"
)
macroBroadcastWindow.Show(
"x"
. macroOverlaySettings["X"]
. " y"
. macroOverlaySettings["Y"]
. " w"
. macroOverlayWidth
. " h"
. macroOverlayHeight
. " NA"
)
macroRendererInstance :=
LayeredOverlayRenderer(
macroBroadcastWindow.Hwnd
)
macroOverlayController :=
LiveOverlayController(
macroStyleResolverInstance,
macroTextLayoutInstance,
macroViewportManager,
macroRendererInstance,
macroOverlaySettings[
"BufferCapacity"
],
macroCurrentVisibleLimit
)
macroCurrentRenderedCount := 0
macroValidatorInstance :=
MessageTextValidator(
300
)
macroRepositoryInstance :=
ChatMacroRepository(
macroStoragePath
)
macroTypeMap :=
Map(
"일반", "Normal",
"관리자", "Admin",
"멤버", "Member",
"후원", "Donation",
"공지", "Notice",
"시스템", "System",
"VIP", "VIP"
)
macroTypeLabelMap :=
Map(
"Normal", "일반",
"Admin", "관리자",
"Member", "멤버",
"Donation", "후원",
"Notice", "공지",
"System", "시스템",
"VIP", "VIP"
)
macroValidationPassed := false
categoryDeleteDialogWindow := 0
pendingDeleteCategory := ""
pendingDeleteMacroCount := 0
macroAdminWindow :=
Gui(
"+AlwaysOnTop",
"OBSLiveChat 채팅 매크로 테스트"
)
; 관리자 창은 송출 오버레이와 독립된 TopMost 창으로 둔다.
; 삭제 확인은 별도 커스텀 GUI를 사용한다.
macroAdminWindow.SetFont(
"s10",
"맑은 고딕"
)
macroAdminWindow.AddText(
"x20 y20 w55 h24",
"작성자"
)
macroAuthorInput :=
macroAdminWindow.AddEdit(
"x80 y16 w180 h27",
"관리자"
)
macroAdminWindow.AddText(
"x280 y20 w75 h24",
"메시지 종류"
)
macroTypeDropDown :=
macroAdminWindow.AddDropDownList(
"x360 y16 w130 Choose2",
[
"일반",
"관리자",
"멤버",
"후원",
"공지",
"시스템",
"VIP"
]
)
macroEnterSendCheckBox :=
macroAdminWindow.AddCheckBox(
"x515 y18 w125 h24 Checked",
"Enter 전송 ON"
)
macroSingleClickCheckBox :=
macroAdminWindow.AddCheckBox(
"x650 y18 w175 h24 Checked",
"목록 한 번 클릭 송출"
)
macroSelectedSendCheckBox :=
macroAdminWindow.AddCheckBox(
"x840 y18 w135 h24 Checked",
"선택 송출 ON"
)
macroAdminWindow.AddText(
"x20 y60 w55 h24",
"메시지"
)
macroMessageInput :=
macroAdminWindow.AddEdit(
"x80 y55 w670 h30"
)
macroValidateButton :=
macroAdminWindow.AddButton(
"x765 y54 w95 h32",
"입력 검사"
)
macroSendButton :=
macroAdminWindow.AddButton(
"x870 y54 w95 h32",
"송출"
)
macroAdminWindow.AddText(
"x20 y105 w70 h24",
"카테고리"
)
macroCategoryCombo :=
macroAdminWindow.AddComboBox(
"x95 y100 w180"
)
macroAdminWindow.AddText(
"x300 y105 w75 h24",
"매크로 이름"
)
macroNameInput :=
macroAdminWindow.AddEdit(
"x380 y100 w220 h27"
)
macroRegisterButton :=
macroAdminWindow.AddButton(
"x620 y99 w100 h30",
"매크로 등록"
)
macroDeleteButton :=
macroAdminWindow.AddButton(
"x730 y99 w100 h30",
"선택 삭제"
)
macroSendSelectedButton :=
macroAdminWindow.AddButton(
"x840 y99 w125 h30",
"선택 송출"
)
macroListView :=
macroAdminWindow.AddListView(
"x20 y145 w945 h330 Grid -Multi",
[
"ID",
"매크로 이름",
"종류",
"작성자",
"메시지"
]
)
macroListView.ModifyCol(
1,
0
)
macroListView.ModifyCol(
2,
170
)
macroListView.ModifyCol(
3,
80
)
macroListView.ModifyCol(
4,
120
)
macroListView.ModifyCol(
5,
540
)
macroClearButton :=
macroAdminWindow.AddButton(
"x20 y490 w110 h32",
"화면 전체 지우기"
)
macroCategoryDeleteButton :=
macroAdminWindow.AddButton(
"x140 y490 w125 h32",
"카테고리 삭제"
)
macroAdminWindow.AddText(
"x285 y495 w70 h24",
"표시 개수"
)
macroVisibleLimitInput :=
macroAdminWindow.AddEdit(
"x355 y490 w60 h30 Number Center",
macroCurrentVisibleLimit
)
macroVisibleLimitApplyButton :=
macroAdminWindow.AddButton(
"x425 y490 w70 h32 Default",
"적용"
)
macroStatusText :=
macroAdminWindow.AddText(
"x510 y493 w455 h50",
"매크로를 등록하거나 목록에서 선택하세요."
)
macroAuthorInput.OnEvent(
"Change",
InvalidateMacroValidation
)
macroTypeDropDown.OnEvent(
"Change",
InvalidateMacroValidation
)
macroMessageInput.OnEvent(
"Change",
InvalidateMacroValidation
)
macroEnterSendCheckBox.OnEvent(
"Click",
HandleMacroEnterModeChanged
)
macroSelectedSendCheckBox.OnEvent(
"Click",
HandleSelectedSendModeChanged
)
macroCategoryCombo.OnEvent(
"Change",
RefreshMacroList
)
macroValidateButton.OnEvent(
"Click",
ValidateMacroMessage
)
macroSendButton.OnEvent(
"Click",
SubmitMacroManualMessage
)
macroRegisterButton.OnEvent(
"Click",
RegisterCurrentMacro
)
macroDeleteButton.OnEvent(
"Click",
DeleteSelectedMacro
)
macroSendSelectedButton.OnEvent(
"Click",
SendSelectedMacro
)
macroListView.OnEvent(
"Click",
HandleMacroListClick
)
macroClearButton.OnEvent(
"Click",
ClearMacroOverlay
)
macroCategoryDeleteButton.OnEvent(
"Click",
DeleteSelectedCategory
)
macroVisibleLimitApplyButton.OnEvent(
"Click",
ApplyMacroVisibleLimit
)
macroAdminWindow.OnEvent(
"Close",
ExitMacroTest
)
macroAdminWindow.Show(
"x930 y30 w990 h555"
)
RefreshMacroCategoryChoices(
"전체"
)
UpdateMacroEnterModeUi()
UpdateSelectedSendModeUi()
macroMessageInput.Focus()
macroCurrentRenderedCount :=
macroOverlayController.AddMessage(
"시스템",
"카테고리별 매크로와 표시 개수 설정을 통합 테스트하세요.",
"System"
)
RefreshMacroCategoryChoices(
preferredCategory := "전체"
)
{
global macroRepositoryInstance
global macroCategoryCombo
macroObjects :=
macroRepositoryInstance.LoadAll()
categorySeen := Map()
categories := []
for chatMacroObject in macroObjects
{
if (
!categorySeen.Has(
chatMacroObject.Category
)
)
{
categorySeen[
chatMacroObject.Category
] := true
categories.Push(
chatMacroObject.Category
)
}
}
macroCategoryCombo.Delete()
macroCategoryCombo.Add(
["전체"]
)
for categoryText in categories
{
macroCategoryCombo.Add(
[categoryText]
)
}
selectedIndex := 1
if (preferredCategory != "전체")
{
for categoryIndex, categoryText in categories
{
if (
categoryText
= preferredCategory
)
{
selectedIndex :=
categoryIndex + 1
break
}
}
}
macroCategoryCombo.Choose(
selectedIndex
)
RefreshMacroList()
}
RefreshMacroList(*)
{
global macroRepositoryInstance
global macroCategoryCombo
global macroListView
global macroTypeLabelMap
selectedCategory :=
Trim(
macroCategoryCombo.Text
)
if (selectedCategory = "")
{
selectedCategory := "전체"
}
macroListView.Delete()
macroObjects :=
macroRepositoryInstance.LoadAll()
for chatMacroObject in macroObjects
{
if (
selectedCategory != "전체"
&& chatMacroObject.Category
!= selectedCategory
)
{
continue
}
typeLabel :=
macroTypeLabelMap.Has(
chatMacroObject.MessageType
)
? macroTypeLabelMap[
chatMacroObject.MessageType
]
: chatMacroObject.MessageType
macroListView.Add(
"",
chatMacroObject.Id,
chatMacroObject.Name,
typeLabel,
chatMacroObject.Author,
chatMacroObject.Text
)
}
}
HandleMacroEnterModeChanged(*)
{
InvalidateMacroValidation()
UpdateMacroEnterModeUi()
}
HandleSelectedSendModeChanged(*)
{
UpdateSelectedSendModeUi(
true
)
}
UpdateSelectedSendModeUi(
updateStatus := false
)
{
global macroSelectedSendCheckBox
global macroSendSelectedButton
global macroStatusText
if (macroSelectedSendCheckBox.Value)
{
macroSelectedSendCheckBox.Text :=
"선택 송출 ON"
macroSendSelectedButton.Enabled :=
true
if (updateStatus)
{
macroStatusText.Value :=
"선택 송출 ON: 선택 송출 버튼을 사용할 수 있습니다."
}
return
}
macroSelectedSendCheckBox.Text :=
"선택 송출 OFF"
macroSendSelectedButton.Enabled :=
false
if (updateStatus)
{
macroStatusText.Value :=
"선택 송출 OFF: 선택 송출 버튼이 비활성화되었습니다."
}
}
UpdateMacroEnterModeUi()
{
global macroEnterSendCheckBox
global macroSendButton
global macroStatusText
global macroValidationPassed
if (macroEnterSendCheckBox.Value)
{
macroEnterSendCheckBox.Text :=
"Enter 전송 ON"
macroSendButton.Enabled :=
true
macroStatusText.Value :=
"Enter 전송 ON: Enter 또는 넘버패드 Enter로 즉시 송출"
return
}
macroEnterSendCheckBox.Text :=
"Enter 전송 OFF"
macroSendButton.Enabled :=
macroValidationPassed
macroStatusText.Value :=
"Enter 전송 OFF: 입력 검사 버튼을 눌러야 송출 버튼 활성"
}
InvalidateMacroValidation(*)
{
global macroValidationPassed
global macroEnterSendCheckBox
global macroSendButton
global macroStatusText
macroValidationPassed := false
if (!macroEnterSendCheckBox.Value)
{
macroSendButton.Enabled := false
macroStatusText.Value :=
"내용이 변경되었습니다. 입력 검사를 다시 실행하세요."
}
}
ValidateMacroMessage(*)
{
global macroAuthorInput
global macroMessageInput
global macroValidatorInstance
global macroValidationPassed
global macroSendButton
global macroEnterSendCheckBox
global macroStatusText
authorText :=
Trim(
macroAuthorInput.Value
)
if (authorText = "")
{
macroValidationPassed := false
macroSendButton.Enabled :=
macroEnterSendCheckBox.Value
macroStatusText.Value :=
"작성자를 입력하세요."
macroAuthorInput.Focus()
return false
}
validationResult :=
macroValidatorInstance.Validate(
macroMessageInput.Value
)
if (!validationResult["IsValid"])
{
macroValidationPassed := false
macroSendButton.Enabled :=
macroEnterSendCheckBox.Value
macroStatusText.Value :=
"검사 실패: "
. validationResult["Message"]
macroMessageInput.Focus()
return false
}
macroMessageInput.Value :=
validationResult[
"NormalizedText"
]
macroValidationPassed := true
macroSendButton.Enabled := true
macroStatusText.Value :=
validationResult[
"Message"
]
macroMessageInput.Focus()
return true
}
SubmitMacroManualMessage(*)
{
global macroAuthorInput
global macroMessageInput
global macroTypeDropDown
global macroTypeMap
global macroOverlayController
global macroEnterSendCheckBox
global macroValidationPassed
global macroSendButton
global macroStatusText
global macroCurrentRenderedCount
if (
!macroEnterSendCheckBox.Value
&& !macroValidationPassed
)
{
macroStatusText.Value :=
"Enter 전송 OFF입니다. 먼저 입력 검사를 통과하세요."
macroMessageInput.Focus()
return
}
authorText :=
Trim(
macroAuthorInput.Value
)
messageText :=
Trim(
macroMessageInput.Value
)
if (authorText = "")
{
macroStatusText.Value :=
"작성자를 입력하세요."
macroAuthorInput.Focus()
return
}
if (messageText = "")
{
macroStatusText.Value :=
"메시지를 입력하세요."
macroMessageInput.Focus()
return
}
selectedTypeLabel :=
macroTypeDropDown.Text
if (!macroTypeMap.Has(selectedTypeLabel))
{
macroStatusText.Value :=
"메시지 종류를 선택하세요."
return
}
selectedMessageType :=
macroTypeMap[
selectedTypeLabel
]
visibleCount :=
macroOverlayController.AddMessage(
authorText,
messageText,
selectedMessageType
)
macroCurrentRenderedCount :=
visibleCount
macroMessageInput.Value := ""
macroValidationPassed := false
macroSendButton.Enabled :=
macroEnterSendCheckBox.Value
macroStatusText.Value :=
"송출 완료 / 현재 화면 메시지 "
. visibleCount
. "개"
macroMessageInput.Focus()
}
RegisterCurrentMacro(*)
{
global macroRepositoryInstance
global macroCategoryCombo
global macroNameInput
global macroAuthorInput
global macroMessageInput
global macroTypeDropDown
global macroTypeMap
global macroValidatorInstance
global macroStatusText
categoryText :=
Trim(
macroCategoryCombo.Text
)
if (
categoryText = ""
|| categoryText = "전체"
)
{
macroStatusText.Value :=
"등록할 카테고리 이름을 직접 입력하세요."
macroCategoryCombo.Focus()
return
}
macroName :=
Trim(
macroNameInput.Value
)
if (macroName = "")
{
macroStatusText.Value :=
"매크로 이름을 입력하세요."
macroNameInput.Focus()
return
}
authorText :=
Trim(
macroAuthorInput.Value
)
if (authorText = "")
{
macroStatusText.Value :=
"작성자를 입력하세요."
macroAuthorInput.Focus()
return
}
validationResult :=
macroValidatorInstance.Validate(
macroMessageInput.Value
)
if (!validationResult["IsValid"])
{
macroStatusText.Value :=
"등록 실패: "
. validationResult["Message"]
macroMessageInput.Focus()
return
}
selectedTypeLabel :=
macroTypeDropDown.Text
if (!macroTypeMap.Has(selectedTypeLabel))
{
macroStatusText.Value :=
"메시지 종류를 선택하세요."
return
}
chatMacroObject :=
ChatMacro(
macroRepositoryInstance.CreateId(),
categoryText,
macroName,
authorText,
macroTypeMap[
selectedTypeLabel
],
validationResult[
"NormalizedText"
]
)
macroRepositoryInstance.AddMacro(
chatMacroObject
)
macroNameInput.Value := ""
RefreshMacroCategoryChoices(
categoryText
)
macroStatusText.Value :=
"매크로 등록 완료: "
. chatMacroObject.Name
macroMessageInput.Focus()
}
DeleteSelectedMacro(*)
{
global macroListView
global macroRepositoryInstance
global macroCategoryCombo
global macroStatusText
selectedRow :=
macroListView.GetNext(
0
)
if (selectedRow = 0)
{
macroStatusText.Value :=
"삭제할 매크로를 먼저 선택하세요."
return
}
macroId :=
macroListView.GetText(
selectedRow,
1
)
macroName :=
macroListView.GetText(
selectedRow,
2
)
if (
macroRepositoryInstance.DeleteById(
macroId
)
)
{
RefreshMacroList()
macroStatusText.Value :=
"매크로 삭제 완료: "
. macroName
return
}
macroStatusText.Value :=
"매크로 삭제에 실패했습니다."
}
HandleMacroListClick(
listControl,
rowNumber,
*
)
{
global macroSingleClickCheckBox
; ListView의 Click 이벤트가 전달하는 값은
; 컨트롤 객체와 클릭한 행 번호 두 개이다.
if (
rowNumber <= 0
|| !macroSingleClickCheckBox.Value
)
{
return
}
SendMacroRow(
rowNumber
)
}
SendSelectedMacro(*)
{
global macroListView
global macroStatusText
global macroSelectedSendCheckBox
if (!macroSelectedSendCheckBox.Value)
{
macroStatusText.Value :=
"선택 송출이 OFF입니다."
return
}
selectedRow :=
macroListView.GetNext(
0
)
if (selectedRow = 0)
{
macroStatusText.Value :=
"송출할 매크로를 먼저 선택하세요."
return
}
SendMacroRow(
selectedRow
)
}
SendMacroRow(rowNumber)
{
global macroListView
global macroTypeMap
global macroOverlayController
global macroValidatorInstance
global macroStatusText
global macroCurrentRenderedCount
macroName :=
macroListView.GetText(
rowNumber,
2
)
typeLabel :=
macroListView.GetText(
rowNumber,
3
)
authorText :=
macroListView.GetText(
rowNumber,
4
)
messageText :=
macroListView.GetText(
rowNumber,
5
)
if (!macroTypeMap.Has(typeLabel))
{
macroStatusText.Value :=
"매크로 메시지 종류를 찾을 수 없습니다."
return
}
validationResult :=
macroValidatorInstance.Validate(
messageText
)
if (!validationResult["IsValid"])
{
macroStatusText.Value :=
"매크로 송출 실패: "
. validationResult["Message"]
return
}
visibleCount :=
macroOverlayController.AddMessage(
authorText,
validationResult[
"NormalizedText"
],
macroTypeMap[
typeLabel
]
)
macroCurrentRenderedCount :=
visibleCount
macroStatusText.Value :=
"매크로 송출 완료: "
. macroName
. " / 화면 메시지 "
. visibleCount
. "개"
}
HandleMacroEnterKey()
{
global macroEnterSendCheckBox
global macroStatusText
global macroMessageInput
if (macroEnterSendCheckBox.Value)
{
SubmitMacroManualMessage()
return
}
; Enter 전송 OFF에서는 Enter로 검사하지 않는다.
; 반드시 '입력 검사' 버튼을 눌러야 송출 버튼이 활성화된다.
macroStatusText.Value :=
"Enter 전송 OFF: 입력 검사 버튼을 눌러야 송출할 수 있습니다."
macroMessageInput.Focus()
}
DeleteSelectedCategory(*)
{
global macroCategoryCombo
global macroRepositoryInstance
global macroStatusText
global categoryDeleteDialogWindow
if (
IsObject(categoryDeleteDialogWindow)
&& WinExist(
"ahk_id "
. categoryDeleteDialogWindow.Hwnd
)
)
{
categoryDeleteDialogWindow.Show()
ActivateMacroWindow(
categoryDeleteDialogWindow.Hwnd
)
return
}
categoryText :=
Trim(
macroCategoryCombo.Text
)
if (
categoryText = ""
|| categoryText = "전체"
)
{
macroStatusText.Value :=
"삭제할 카테고리를 선택하세요. '전체'는 삭제할 수 없습니다."
macroCategoryCombo.Focus()
return
}
macroCount :=
macroRepositoryInstance.CountByCategory(
categoryText
)
if (macroCount <= 0)
{
macroStatusText.Value :=
"선택한 카테고리에 저장된 매크로가 없습니다."
macroCategoryCombo.Focus()
return
}
ShowCategoryDeleteDialog(
categoryText,
macroCount
)
}
ShowCategoryDeleteDialog(
categoryText,
macroCount
)
{
global macroAdminWindow
global categoryDeleteDialogWindow
global pendingDeleteCategory
global pendingDeleteMacroCount
pendingDeleteCategory :=
categoryText
pendingDeleteMacroCount :=
macroCount
; 기본 MsgBox를 사용하지 않고 관리자 창 소유의
; 커스텀 확인창을 직접 만든다.
categoryDeleteDialogWindow :=
Gui(
"+AlwaysOnTop +ToolWindow -MinimizeBox -MaximizeBox"
. " +Owner"
. macroAdminWindow.Hwnd,
"카테고리 삭제 확인"
)
categoryDeleteDialogWindow.SetFont(
"s10",
"맑은 고딕"
)
categoryDeleteDialogWindow.AddText(
"x25 y22 w390 h70 Center",
"카테고리 ["
. categoryText
. "]의 매크로 "
. macroCount
. "개가 모두 삭제됩니다."
. "`n`n계속하시겠습니까?"
)
confirmDeleteButton :=
categoryDeleteDialogWindow.AddButton(
"x95 y112 w100 h34",
"삭제"
)
cancelDeleteButton :=
categoryDeleteDialogWindow.AddButton(
"x225 y112 w100 h34 Default",
"취소"
)
confirmDeleteButton.OnEvent(
"Click",
ConfirmCategoryDelete
)
cancelDeleteButton.OnEvent(
"Click",
CancelCategoryDelete
)
categoryDeleteDialogWindow.OnEvent(
"Close",
CancelCategoryDelete
)
categoryDeleteDialogWindow.OnEvent(
"Escape",
CancelCategoryDelete
)
; 확인창이 떠 있는 동안 관리자 창 입력을 막는다.
DllCall(
"EnableWindow",
"Ptr", macroAdminWindow.Hwnd,
"Int", false,
"Int"
)
macroAdminWindow.GetPos(
&adminX,
&adminY,
&adminWidth,
&adminHeight
)
dialogWidth := 420
dialogHeight := 175
dialogX :=
adminX
+ Floor(
(
adminWidth
- dialogWidth
)
/ 2
)
dialogY :=
adminY
+ Floor(
(
adminHeight
- dialogHeight
)
/ 2
)
categoryDeleteDialogWindow.Show(
"x"
. dialogX
. " y"
. dialogY
. " w"
. dialogWidth
. " h"
. dialogHeight
)
ActivateMacroWindow(
categoryDeleteDialogWindow.Hwnd
)
}
ConfirmCategoryDelete(*)
{
global macroRepositoryInstance
global macroStatusText
global pendingDeleteCategory
global pendingDeleteMacroCount
categoryText :=
pendingDeleteCategory
deletedCount :=
macroRepositoryInstance.DeleteByCategory(
categoryText
)
CloseCategoryDeleteDialog()
if (deletedCount <= 0)
{
macroStatusText.Value :=
"카테고리 삭제에 실패했습니다."
return
}
RefreshMacroCategoryChoices(
"전체"
)
macroStatusText.Value :=
"카테고리 삭제 완료: "
. categoryText
. " / 매크로 "
. deletedCount
. "개 삭제"
}
CancelCategoryDelete(*)
{
global macroStatusText
CloseCategoryDeleteDialog()
macroStatusText.Value :=
"카테고리 삭제를 취소했습니다."
}
CloseCategoryDeleteDialog()
{
global macroAdminWindow
global categoryDeleteDialogWindow
global pendingDeleteCategory
global pendingDeleteMacroCount
if (
IsObject(categoryDeleteDialogWindow)
&& WinExist(
"ahk_id "
. categoryDeleteDialogWindow.Hwnd
)
)
{
categoryDeleteDialogWindow.Destroy()
}
categoryDeleteDialogWindow := 0
pendingDeleteCategory := ""
pendingDeleteMacroCount := 0
DllCall(
"EnableWindow",
"Ptr", macroAdminWindow.Hwnd,
"Int", true,
"Int"
)
; 대화상자 파괴가 끝난 다음 관리자 창을 확실히 맨 앞으로 복원.
SetTimer(
RestoreMacroAdminWindow,
-30
)
}
RestoreMacroAdminWindow()
{
global macroAdminWindow
if (
!IsObject(macroAdminWindow)
|| !WinExist(
"ahk_id "
. macroAdminWindow.Hwnd
)
)
{
return
}
ActivateMacroWindow(
macroAdminWindow.Hwnd
)
}
ActivateMacroWindow(windowHandle)
{
if (!windowHandle)
{
return
}
; HWND_TOPMOST = -1
; SWP_NOSIZE | SWP_NOMOVE | SWP_SHOWWINDOW = 0x43
DllCall(
"SetWindowPos",
"Ptr", windowHandle,
"Ptr", -1,
"Int", 0,
"Int", 0,
"Int", 0,
"Int", 0,
"UInt", 0x43,
"Int"
)
DllCall(
"ShowWindow",
"Ptr", windowHandle,
"Int", 5,
"Int"
)
DllCall(
"SetForegroundWindow",
"Ptr", windowHandle,
"Int"
)
}
ApplyMacroVisibleLimit(*)
{
global macroVisibleLimitInput
global macroOverlaySettings
global macroOverlaySettingsPath
global macroStatusText
rawVisibleLimit :=
Trim(
macroVisibleLimitInput.Value
)
if (
rawVisibleLimit = ""
|| !RegExMatch(
rawVisibleLimit,
"^\d+$"
)
)
{
macroStatusText.Value :=
"표시 개수는 1~"
. macroOverlaySettings[
"BufferCapacity"
]
. " 사이의 숫자로 입력하세요."
macroVisibleLimitInput.Focus()
return
}
visibleLimit :=
rawVisibleLimit + 0
if (
visibleLimit < 1
|| visibleLimit
> macroOverlaySettings[
"BufferCapacity"
]
)
{
macroStatusText.Value :=
"표시 개수 범위: 1~"
. macroOverlaySettings[
"BufferCapacity"
]
macroVisibleLimitInput.Focus()
return
}
actualRenderedCount :=
SetMacroVisibleLimit(
visibleLimit
)
IniWrite(
visibleLimit,
macroOverlaySettingsPath,
"Messages",
"VisibleMessageLimit"
)
macroStatusText.Value :=
"표시 개수 "
. visibleLimit
. "개 적용 / 현재 화면 "
. actualRenderedCount
. "개 / 설정 저장 완료"
macroVisibleLimitInput.Focus()
}
SetMacroVisibleLimit(
visibleLimit
)
{
global macroCurrentVisibleLimit
global macroCurrentRenderedCount
global macroOverlayHeight
global macroVisibleWindowSizer
global macroStyleConfiguration
global macroOverlaySettings
global macroBroadcastWindow
global macroViewportManager
global macroOverlayController
global macroVisibleLimitInput
macroCurrentVisibleLimit :=
visibleLimit
macroOverlayHeight :=
macroVisibleWindowSizer.CalculateHeight(
visibleLimit,
macroStyleConfiguration[
"Styles"
]
)
; X/Y를 처음 설정값으로 유지한다.
; 위쪽은 고정되고 아래쪽 경계만 움직인다.
WinMove(
macroOverlaySettings["X"],
macroOverlaySettings["Y"],
macroOverlaySettings["Width"],
macroOverlayHeight,
"ahk_id "
. macroBroadcastWindow.Hwnd
)
macroViewportManager.ViewportHeight :=
macroOverlayHeight
macroCurrentRenderedCount :=
macroOverlayController.SetVisibleMessageLimit(
visibleLimit
)
macroVisibleLimitInput.Value :=
visibleLimit
return macroCurrentRenderedCount
}
ClearMacroOverlay(*)
{
global macroOverlayController
global macroStatusText
global macroMessageInput
global macroCurrentRenderedCount
macroOverlayController.ClearMessages()
macroCurrentRenderedCount := 0
macroStatusText.Value :=
"오버레이 메시지를 모두 지웠습니다."
macroMessageInput.Focus()
}
ExitMacroTest(*)
{
global macroRendererInstance
global categoryDeleteDialogWindow
if (
IsObject(categoryDeleteDialogWindow)
&& WinExist(
"ahk_id "
. categoryDeleteDialogWindow.Hwnd
)
)
{
categoryDeleteDialogWindow.Destroy()
}
macroRendererInstance.Shutdown()
ExitApp()
}
#HotIf WinActive("OBSLiveChat 채팅 매크로 테스트")
Enter::
{
HandleMacroEnterKey()
}
NumpadEnter::
{
HandleMacroEnterKey()
}
#HotIf
Esc::
{
ExitMacroTest()
}
🔴 Tests/ManualInputOverlayTest.ahk — 관리자 수동 입력 + 실시간 오버레이 테스트 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Services\LiveOverlayController.ahk
#Include ..\Engine\Rendering\LayeredOverlayRenderer.ahk
;============================================================
; Build 2.2 - 관리자 수동 입력 + 실시간 오버레이
;
; 관리자 창:
; - 작성자 입력
; - 메시지 종류 선택
; - 메시지 입력
; - 송출 버튼
; - 전체 지우기
;
; 키:
; - 일반 Enter: 송출
; - 넘버패드 Enter: 송출
; - Esc: 종료
;============================================================
manualInputStylePath :=
A_ScriptDir
. "\..\Data\Settings\Styles.ini"
manualInputStyleLoader :=
StyleLoader()
manualInputStyleConfiguration :=
manualInputStyleLoader.Load(
manualInputStylePath
)
manualInputStyleResolver :=
StyleResolver(
manualInputStyleConfiguration
)
manualInputTextMeasurer :=
GdiTextMeasurer()
manualInputTextLayout :=
TextLayout(
45,
25,
40,
13,
manualInputTextMeasurer,
720,
5
)
broadcastWindowWidth := 900
broadcastWindowHeight := 720
manualInputViewportManager :=
ViewportLayout(
manualInputTextLayout,
broadcastWindowHeight,
25,
30
)
broadcastGuiWindow :=
Gui(
"+AlwaysOnTop -Caption +ToolWindow +E0x80000 +E0x20",
"OBSLiveChat 송출 오버레이"
)
broadcastGuiWindow.Show(
"x20 y20 w"
. broadcastWindowWidth
. " h"
. broadcastWindowHeight
. " NA"
)
manualInputRenderer :=
LayeredOverlayRenderer(
broadcastGuiWindow.Hwnd
)
overlayControllerInstance :=
LiveOverlayController(
manualInputStyleResolver,
manualInputTextLayout,
manualInputViewportManager,
manualInputRenderer,
100
)
messageTypeMap :=
Map(
"일반", "Normal",
"관리자", "Admin",
"멤버", "Member",
"후원", "Donation",
"공지", "Notice",
"시스템", "System",
"VIP", "VIP"
)
adminGuiWindow :=
Gui(
"+AlwaysOnTop",
"OBSLiveChat 관리자 입력 테스트"
)
adminGuiWindow.SetFont(
"s10",
"맑은 고딕"
)
adminGuiWindow.AddText(
"x20 y22 w60 h24",
"작성자"
)
adminAuthorInput :=
adminGuiWindow.AddEdit(
"x90 y18 w220 h26",
"관리자"
)
adminGuiWindow.AddText(
"x335 y22 w80 h24",
"메시지 종류"
)
adminMessageTypeDropDown :=
adminGuiWindow.AddDropDownList(
"x425 y18 w160 Choose2",
[
"일반",
"관리자",
"멤버",
"후원",
"공지",
"시스템",
"VIP"
]
)
adminGuiWindow.AddText(
"x20 y65 w60 h24",
"메시지"
)
adminMessageInput :=
adminGuiWindow.AddEdit(
"x90 y60 w640 h30"
)
adminSendButton :=
adminGuiWindow.AddButton(
"x745 y59 w100 h32",
"송출"
)
adminClearButton :=
adminGuiWindow.AddButton(
"x745 y103 w100 h32",
"전체 지우기"
)
adminStatusText :=
adminGuiWindow.AddText(
"x90 y108 w620 h24",
"일반 Enter 또는 넘버패드 Enter로 송출"
)
adminSendButton.OnEvent(
"Click",
SubmitAdminMessage
)
adminClearButton.OnEvent(
"Click",
ClearOverlayMessages
)
adminGuiWindow.OnEvent(
"Close",
ExitManualInputTest
)
adminGuiWindow.Show(
"x950 y40 w870 h155"
)
adminMessageInput.Focus()
overlayControllerInstance.AddMessage(
"시스템",
"관리자 입력창에서 메시지를 작성한 뒤 Enter 또는 넘버패드 Enter를 누르세요.",
"System"
)
SubmitAdminMessage(*)
{
global adminAuthorInput
global adminMessageInput
global adminMessageTypeDropDown
global adminStatusText
global messageTypeMap
global overlayControllerInstance
authorText :=
Trim(
adminAuthorInput.Value
)
messageText :=
Trim(
adminMessageInput.Value
)
if (authorText = "")
{
adminStatusText.Value :=
"작성자를 입력하세요."
adminAuthorInput.Focus()
return
}
if (messageText = "")
{
adminStatusText.Value :=
"메시지를 입력하세요."
adminMessageInput.Focus()
return
}
selectedTypeLabel :=
adminMessageTypeDropDown.Text
if (
!messageTypeMap.Has(
selectedTypeLabel
)
)
{
adminStatusText.Value :=
"메시지 종류를 선택하세요."
return
}
selectedMessageType :=
messageTypeMap[
selectedTypeLabel
]
visibleMessageCount :=
overlayControllerInstance.AddMessage(
authorText,
messageText,
selectedMessageType
)
adminMessageInput.Value := ""
adminStatusText.Value :=
"송출 완료 / 현재 화면 메시지 "
. visibleMessageCount
. "개"
adminMessageInput.Focus()
}
ClearOverlayMessages(*)
{
global overlayControllerInstance
global adminMessageInput
global adminStatusText
overlayControllerInstance.ClearMessages()
adminStatusText.Value :=
"오버레이 메시지를 모두 지웠습니다."
adminMessageInput.Focus()
}
ExitManualInputTest(*)
{
global manualInputRenderer
manualInputRenderer.Shutdown()
ExitApp()
}
#HotIf WinActive("OBSLiveChat 관리자 입력 테스트")
Enter::
{
SubmitAdminMessage()
}
NumpadEnter::
{
SubmitAdminMessage()
}
#HotIf
Esc::
{
ExitManualInputTest()
}
🔴 Tests/MessageBufferTest.ahk — 메시지 버퍼 개수 제한 검사 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Model\ChatMessage.ahk
#Include ..\Engine\Model\MessageBuffer.ahk
;============================================================
; MessageBufferTest
;
; 최대 20개 버퍼에 메시지 25개를 넣고,
; 최근 20개만 남는지 확인한다.
;============================================================
messageBufferInstance := MessageBuffer(20)
Loop 25
{
messageNumber := A_Index
chatMessageInstance := ChatMessage(
"사용자 " messageNumber,
"테스트 메시지 " messageNumber
)
messageBufferInstance.Add(chatMessageInstance)
}
storedMessageCount := messageBufferInstance.GetCount()
oldestMessage := messageBufferInstance.GetOldest()
newestMessage := messageBufferInstance.GetNewest()
testPassed :=
(
storedMessageCount = 20
&& oldestMessage.UserName = "사용자 6"
&& newestMessage.UserName = "사용자 25"
)
resultText :=
(
"저장된 메시지 수: " storedMessageCount
. "`n"
. "가장 오래된 메시지: "
. oldestMessage.ToDisplayText()
. "`n"
. "가장 최근 메시지: "
. newestMessage.ToDisplayText()
. "`n`n"
. (testPassed
? "MessageBuffer 테스트 성공"
: "MessageBuffer 테스트 실패")
)
MsgBox(
resultText,
"Build 0.4 - MessageBuffer Test"
)
🔴 Tests/MessagePipelineTest.ahk — 버퍼 → 스타일 → 배치 → 그리기 명령 전체 연결 검사 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Services\MessagePipeline.ahk
;============================================================
; MessagePipelineTest
;
; MessageBuffer → StyleResolver → TextLayout →
; TextDrawCommand[] 전체 연결을 검사한다.
;============================================================
;------------------------------------------------------------
; Styles.ini 불러오기
;------------------------------------------------------------
pipelineTestStyleFilePath :=
A_ScriptDir "\..\Data\Settings\Styles.ini"
pipelineTestStyleLoader :=
StyleLoader()
pipelineTestStyleConfiguration :=
pipelineTestStyleLoader.Load(
pipelineTestStyleFilePath
)
pipelineTestStyleResolver :=
StyleResolver(
pipelineTestStyleConfiguration
)
;------------------------------------------------------------
; 엔진 객체 생성
;------------------------------------------------------------
pipelineTestMessageBuffer :=
MessageBuffer(20)
pipelineTestTextLayout :=
TextLayout(
30, ; 왼쪽 여백
40, ; 위쪽 여백
36 ; 줄 높이
)
pipelineTestInstance :=
MessagePipeline(
pipelineTestMessageBuffer,
pipelineTestStyleResolver,
pipelineTestTextLayout
)
;------------------------------------------------------------
; 서로 다른 메시지 종류 추가
;------------------------------------------------------------
pipelineTestMessageBuffer.Add(
ChatMessage(
"일반 사용자",
"일반 메시지",
"Normal"
)
)
pipelineTestMessageBuffer.Add(
ChatMessage(
"관리자",
"관리자 메시지",
"Admin"
)
)
pipelineTestMessageBuffer.Add(
ChatMessage(
"멤버",
"멤버 메시지",
"Member"
)
)
pipelineTestMessageBuffer.Add(
ChatMessage(
"후원자",
"후원 메시지",
"Donation"
)
)
;------------------------------------------------------------
; 전체 Pipeline 실행
;------------------------------------------------------------
pipelineTestDrawCommands :=
pipelineTestInstance.BuildDrawCommands()
;------------------------------------------------------------
; 명령 개수와 좌표 검사
;------------------------------------------------------------
pipelineTestCountPassed :=
(
pipelineTestInstance.GetMessageCount() = 4
&& pipelineTestDrawCommands.Length = 4
)
pipelineTestPositionPassed :=
(
pipelineTestDrawCommands[1].X = 30
&& pipelineTestDrawCommands[1].Y = 40
&& pipelineTestDrawCommands[2].X = 30
&& pipelineTestDrawCommands[2].Y = 76
&& pipelineTestDrawCommands[3].X = 30
&& pipelineTestDrawCommands[3].Y = 112
&& pipelineTestDrawCommands[4].X = 30
&& pipelineTestDrawCommands[4].Y = 148
)
;------------------------------------------------------------
; 출력 문자열 검사
;------------------------------------------------------------
pipelineTestTextPassed :=
(
pipelineTestDrawCommands[1].Text
= "[일반 사용자] 일반 메시지"
&& pipelineTestDrawCommands[2].Text
= "[관리자] 관리자 메시지"
&& pipelineTestDrawCommands[3].Text
= "[멤버] 멤버 메시지"
&& pipelineTestDrawCommands[4].Text
= "[후원자] 후원 메시지"
)
;------------------------------------------------------------
; 메시지별 스타일 검사
;------------------------------------------------------------
pipelineTestStylePassed :=
(
pipelineTestDrawCommands[1].Style.Color
= "FFFFFF"
&& pipelineTestDrawCommands[2].Style.Color
= "FFD700"
&& pipelineTestDrawCommands[2].Style.FontWeight
= 700
&& pipelineTestDrawCommands[3].Style.Color
= "00FF7F"
&& pipelineTestDrawCommands[4].Style.Color
= "00BFFF"
&& pipelineTestDrawCommands[4].Style.FontSize
= 24
&& pipelineTestDrawCommands[4].Style.BackgroundEnabled
)
;------------------------------------------------------------
; 잘못된 의존 객체 거부 검사
;------------------------------------------------------------
pipelineTestInvalidDependencyRejected := false
try
{
pipelineTestInvalidInstance :=
MessagePipeline(
"MessageBuffer가 아닌 문자열",
pipelineTestStyleResolver,
pipelineTestTextLayout
)
}
catch TypeError
{
pipelineTestInvalidDependencyRejected := true
}
;------------------------------------------------------------
; 최종 결과
;------------------------------------------------------------
pipelineTestPassed :=
(
pipelineTestCountPassed
&& pipelineTestPositionPassed
&& pipelineTestTextPassed
&& pipelineTestStylePassed
&& pipelineTestInvalidDependencyRejected
)
pipelineTestResultText := ""
pipelineTestResultText .=
"메시지 수: "
. pipelineTestInstance.GetMessageCount()
. "`n"
pipelineTestResultText .=
"DrawCommand 수: "
. pipelineTestDrawCommands.Length
. "`n`n"
pipelineTestResultText .=
"일반: #"
. pipelineTestDrawCommands[1].Style.Color
. " / 좌표 "
. pipelineTestDrawCommands[1].X
. ", "
. pipelineTestDrawCommands[1].Y
. "`n"
pipelineTestResultText .=
"관리자: #"
. pipelineTestDrawCommands[2].Style.Color
. " / 좌표 "
. pipelineTestDrawCommands[2].X
. ", "
. pipelineTestDrawCommands[2].Y
. "`n"
pipelineTestResultText .=
"멤버: #"
. pipelineTestDrawCommands[3].Style.Color
. " / 좌표 "
. pipelineTestDrawCommands[3].X
. ", "
. pipelineTestDrawCommands[3].Y
. "`n"
pipelineTestResultText .=
"후원: #"
. pipelineTestDrawCommands[4].Style.Color
. " / 좌표 "
. pipelineTestDrawCommands[4].X
. ", "
. pipelineTestDrawCommands[4].Y
. "`n`n"
pipelineTestResultText .=
"명령 개수 검사: "
. (pipelineTestCountPassed ? "성공" : "실패")
. "`n"
pipelineTestResultText .=
"좌표 계산 검사: "
. (pipelineTestPositionPassed ? "성공" : "실패")
. "`n"
pipelineTestResultText .=
"메시지 문자열 검사: "
. (pipelineTestTextPassed ? "성공" : "실패")
. "`n"
pipelineTestResultText .=
"종류별 스타일 검사: "
. (pipelineTestStylePassed ? "성공" : "실패")
. "`n"
pipelineTestResultText .=
"잘못된 의존 객체 거부: "
. (
pipelineTestInvalidDependencyRejected
? "성공"
: "실패"
)
. "`n`n"
pipelineTestResultText .=
(
pipelineTestPassed
? "MessagePipeline 테스트 성공"
: "MessagePipeline 테스트 실패"
)
MsgBox(
pipelineTestResultText,
"Build 1.3 - MessagePipeline Test"
)
🔴 Tests/OverlayRendererTest.ahk — Styles.ini부터 렌더러까지 실제 창에 그려보기 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Services\MessagePipeline.ahk
#Include ..\Engine\Rendering\OverlayRenderer.ahk
;============================================================
; OverlayRendererTest
;
; Styles.ini부터 MessagePipeline과 OverlayRenderer까지
; 연결하여 실제 창에 종류별 메시지를 그린다.
;============================================================
;------------------------------------------------------------
; 스타일 엔진 생성
;------------------------------------------------------------
rendererTestStyleFilePath := A_ScriptDir "\..\Data\Settings\Styles.ini"
rendererTestStyleLoader :=
StyleLoader()
rendererTestStyleConfiguration :=
rendererTestStyleLoader.Load(
rendererTestStyleFilePath
)
rendererTestStyleResolver :=
StyleResolver(
rendererTestStyleConfiguration
)
;------------------------------------------------------------
; 메시지 엔진 생성
;------------------------------------------------------------
rendererTestMessageBuffer :=
MessageBuffer(20)
rendererTestTextMeasurer :=
GdiTextMeasurer()
rendererTestTextLayout :=
TextLayout(
30, ; 왼쪽 여백
40, ; 위쪽 여백
40, ; 최소 줄 높이
12, ; 메시지 사이 간격
rendererTestTextMeasurer
)
rendererTestMessagePipeline :=
MessagePipeline(
rendererTestMessageBuffer,
rendererTestStyleResolver,
rendererTestTextLayout
)
;------------------------------------------------------------
; 테스트 메시지 추가
;------------------------------------------------------------
rendererTestMessageBuffer.Add(
ChatMessage(
"일반 사용자",
"일반 메시지입니다.",
"Normal"
)
)
rendererTestMessageBuffer.Add(
ChatMessage(
"관리자",
"관리자 메시지입니다.",
"Admin"
)
)
rendererTestMessageBuffer.Add(
ChatMessage(
"멤버",
"멤버 메시지입니다.",
"Member"
)
)
rendererTestMessageBuffer.Add(
ChatMessage(
"후원자",
"후원 메시지입니다.",
"Donation"
)
)
rendererTestMessageBuffer.Add(
ChatMessage(
"공지",
"공지 메시지입니다.",
"Notice"
)
)
rendererTestMessageBuffer.Add(
ChatMessage(
"시스템",
"시스템 메시지입니다.",
"System"
)
)
rendererTestMessageBuffer.Add(
ChatMessage(
"VIP",
"VIP 메시지입니다.",
"VIP"
)
)
;------------------------------------------------------------
; DrawCommand 배열 생성
;------------------------------------------------------------
rendererTestDrawCommands :=
rendererTestMessagePipeline.BuildDrawCommands()
;------------------------------------------------------------
; 테스트 창과 Renderer 생성
;------------------------------------------------------------
rendererTestWindow := Gui(
"+Resize +MinSize700x500",
"Build 1.7.2 - GDI Measured Layout Test"
)
rendererTestWindow.BackColor :=
"000000"
rendererTestWindow.OnEvent(
"Close",
HandleRendererTestClose
)
rendererTestRenderer :=
OverlayRenderer(
rendererTestWindow.Hwnd,
"303030"
)
rendererTestWindow.Show(
"w850 h520 Center"
)
rendererTestRenderer.SetDrawCommands(
rendererTestDrawCommands
)
;------------------------------------------------------------
; 눈으로 확인할 항목
;
; Normal 흰색
; Admin 금색
; Member 초록
; Donation 하늘색 + 더 큰 글꼴
; Notice 주황
; System 회색 + 더 작은 글꼴
; VIP 분홍
;------------------------------------------------------------
HandleRendererTestClose(*)
{
ExitApp()
}
🔴 Tests/StyleEncodingTest.ahk — Styles.ini를 UTF-8로 직접 읽어 확인 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
styleFilePath :=
A_ScriptDir "\..\Data\Settings\Styles.ini"
fileText := FileRead(
styleFilePath,
"UTF-8"
)
MsgBox(
fileText,
"Styles.ini UTF-8 직접 읽기 테스트"
)
🔴 Tests/StyleLoaderBooleanTest.ahk — 스타일 로더의 Boolean 값 처리 검사 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
loaderBooleanTestInstance := StyleLoader()
trueTestPassed :=
(
loaderBooleanTestInstance.ParseBoolean("True", "테스트")
&& loaderBooleanTestInstance.ParseBoolean("YES", "테스트")
&& loaderBooleanTestInstance.ParseBoolean("on", "테스트")
&& loaderBooleanTestInstance.ParseBoolean("1", "테스트")
)
falseTestPassed :=
(
!loaderBooleanTestInstance.ParseBoolean("False", "테스트")
&& !loaderBooleanTestInstance.ParseBoolean("NO", "테스트")
&& !loaderBooleanTestInstance.ParseBoolean("off", "테스트")
&& !loaderBooleanTestInstance.ParseBoolean("0", "테스트")
)
invalidBooleanRejected := false
try
{
loaderBooleanTestInstance.ParseBoolean(
"Maybe",
"테스트 논리값"
)
}
catch ValueError
{
invalidBooleanRejected := true
}
loaderBooleanTestPassed :=
(
trueTestPassed
&& falseTestPassed
&& invalidBooleanRejected
)
resultText :=
(
"True / Yes / On / 1 변환: "
. (trueTestPassed ? "성공" : "실패")
. "`n"
. "False / No / Off / 0 변환: "
. (falseTestPassed ? "성공" : "실패")
. "`n"
. "잘못된 논리값 거부: "
. (invalidBooleanRejected ? "성공" : "실패")
. "`n`n"
. (
loaderBooleanTestPassed
? "ParseBoolean 테스트 성공"
: "ParseBoolean 테스트 실패"
)
)
MsgBox(
resultText,
"Build 1.2.2 - ParseBoolean Test"
)
🔴 Tests/StyleLoaderTest.ahk — Styles.ini → 스타일 표 변환 검사 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Model\ChatMessage.ahk
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Pipeline\StyleResolver.ahk
;============================================================
; StyleLoaderTest
;
; Styles.ini가 StyleTable Map으로 변환되고,
; StyleResolver가 해당 테이블을 올바르게 사용하는지
; 검사한다.
;============================================================
styleLoaderTestFilePath :=
A_ScriptDir "\..\Data\Settings\Styles.ini"
styleLoaderTestInstance :=
StyleLoader()
styleLoaderTestConfiguration :=
styleLoaderTestInstance.Load(
styleLoaderTestFilePath
)
styleLoaderTestResolver :=
StyleResolver(
styleLoaderTestConfiguration
)
;------------------------------------------------------------
; 테스트 메시지 생성
;------------------------------------------------------------
styleLoaderTestNormalMessage := ChatMessage(
"일반 사용자",
"일반 메시지",
"Normal"
)
styleLoaderTestAdminMessage := ChatMessage(
"관리자",
"관리자 메시지",
"Admin"
)
styleLoaderTestVipMessage := ChatMessage(
"VIP 사용자",
"VIP 메시지",
"VIP"
)
styleLoaderTestUnknownMessage := ChatMessage(
"알 수 없음",
"미등록 종류",
"Unknown"
)
;------------------------------------------------------------
; 스타일 조회
;------------------------------------------------------------
styleLoaderTestNormalStyle :=
styleLoaderTestResolver.Resolve(
styleLoaderTestNormalMessage
)
styleLoaderTestAdminStyle :=
styleLoaderTestResolver.Resolve(
styleLoaderTestAdminMessage
)
styleLoaderTestVipStyle :=
styleLoaderTestResolver.Resolve(
styleLoaderTestVipMessage
)
styleLoaderTestFallbackStyle :=
styleLoaderTestResolver.Resolve(
styleLoaderTestUnknownMessage
)
;------------------------------------------------------------
; 설정값 검사
;------------------------------------------------------------
styleLoaderTestDefaultsPassed :=
(
styleLoaderTestConfiguration["FontName"]
= "맑은 고딕"
&& styleLoaderTestConfiguration["FontSize"]
= 22
&& styleLoaderTestConfiguration["FallbackType"]
= "normal"
)
styleLoaderTestTablePassed :=
(
styleLoaderTestResolver.GetStyleCount() = 7
&& styleLoaderTestResolver.HasStyle("normal")
&& styleLoaderTestResolver.HasStyle("admin")
&& styleLoaderTestResolver.HasStyle("vip")
)
styleLoaderTestNormalPassed :=
(
styleLoaderTestNormalStyle.Color = "FFFFFF"
&& styleLoaderTestNormalStyle.FontWeight = 400
)
styleLoaderTestAdminPassed :=
(
styleLoaderTestAdminStyle.Color = "FFD700"
&& styleLoaderTestAdminStyle.FontWeight = 700
)
styleLoaderTestVipPassed :=
(
styleLoaderTestVipStyle.Color = "FF69B4"
&& styleLoaderTestVipStyle.FontWeight = 800
)
styleLoaderTestFallbackPassed :=
(
styleLoaderTestFallbackStyle.Color = "FFFFFF"
&& styleLoaderTestFallbackStyle.FontWeight = 400
)
styleLoaderTestInvalidFileRejected := false
try
{
styleLoaderTestInstance.Load(
A_ScriptDir "\없는파일.ini"
)
}
catch Error
{
styleLoaderTestInvalidFileRejected := true
}
;------------------------------------------------------------
; 전체 테스트 결과
;------------------------------------------------------------
styleLoaderTestPassed :=
(
styleLoaderTestDefaultsPassed
&& styleLoaderTestTablePassed
&& styleLoaderTestNormalPassed
&& styleLoaderTestAdminPassed
&& styleLoaderTestVipPassed
&& styleLoaderTestFallbackPassed
&& styleLoaderTestInvalidFileRejected
)
;------------------------------------------------------------
; 결과 출력
;------------------------------------------------------------
styleLoaderTestResultText :=
(
"글꼴: "
. styleLoaderTestConfiguration["FontName"]
. " / "
. styleLoaderTestConfiguration["FontSize"]
. "pt"
. "`n"
. "기본 타입: "
. styleLoaderTestConfiguration["FallbackType"]
. "`n"
. "불러온 스타일 수: "
. styleLoaderTestResolver.GetStyleCount()
. "`n`n"
. "일반: #"
. styleLoaderTestNormalStyle.Color
. " / Weight "
. styleLoaderTestNormalStyle.FontWeight
. "`n"
. "관리자: #"
. styleLoaderTestAdminStyle.Color
. " / Weight "
. styleLoaderTestAdminStyle.FontWeight
. "`n"
. "VIP: #"
. styleLoaderTestVipStyle.Color
. " / Weight "
. styleLoaderTestVipStyle.FontWeight
. "`n`n"
. "미등록 종류 기본 처리: "
. (
styleLoaderTestFallbackPassed
? "성공"
: "실패"
)
. "`n"
. "없는 파일 거부: "
. (
styleLoaderTestInvalidFileRejected
? "성공"
: "실패"
)
. "`n`n"
. (
styleLoaderTestPassed
? "StyleLoader 테스트 성공"
: "StyleLoader 테스트 실패"
)
)
MsgBox(
styleLoaderTestResultText,
"Build 1.2 - StyleLoader Test"
)
🔴 Tests/StyleResolverTest.ahk — 스타일 조회·기본 처리·잘못된 객체 거부 검사 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Model\ChatMessage.ahk
#Include ..\Engine\Pipeline\StyleResolver.ahk
;============================================================
; StyleResolverTest
;
; 기본 스타일 조회, 미등록 종류의 기본 처리,
; 동적 스타일 등록, 잘못된 객체 거부를 검사한다.
;============================================================
resolverTestInstance := StyleResolver(
"맑은 고딕",
22
)
;------------------------------------------------------------
; 테스트용 ChatMessage 생성
;------------------------------------------------------------
resolverTestNormalMessage := ChatMessage(
"일반 사용자",
"일반 메시지",
"Normal"
)
resolverTestAdminMessage := ChatMessage(
"관리자",
"관리자 메시지",
"Admin"
)
resolverTestMemberMessage := ChatMessage(
"멤버",
"멤버 메시지",
"Member"
)
resolverTestDonationMessage := ChatMessage(
"후원자",
"후원 메시지",
"Donation"
)
resolverTestNoticeMessage := ChatMessage(
"공지",
"공지 메시지",
"Notice"
)
resolverTestSystemMessage := ChatMessage(
"시스템",
"시스템 메시지",
"System"
)
resolverTestUnknownMessage := ChatMessage(
"알 수 없음",
"등록되지 않은 메시지 종류",
"Unknown"
)
;------------------------------------------------------------
; 기본 스타일 조회
;------------------------------------------------------------
resolverTestNormalStyle :=
resolverTestInstance.Resolve(
resolverTestNormalMessage
)
resolverTestAdminStyle :=
resolverTestInstance.Resolve(
resolverTestAdminMessage
)
resolverTestMemberStyle :=
resolverTestInstance.Resolve(
resolverTestMemberMessage
)
resolverTestDonationStyle :=
resolverTestInstance.Resolve(
resolverTestDonationMessage
)
resolverTestNoticeStyle :=
resolverTestInstance.Resolve(
resolverTestNoticeMessage
)
resolverTestSystemStyle :=
resolverTestInstance.Resolve(
resolverTestSystemMessage
)
resolverTestFallbackStyle :=
resolverTestInstance.Resolve(
resolverTestUnknownMessage
)
;------------------------------------------------------------
; 동적 VIP 스타일 등록
;------------------------------------------------------------
resolverTestInstance.RegisterStyle(
"vip",
"FF69B4",
800
)
resolverTestVipMessage := ChatMessage(
"VIP 사용자",
"VIP 메시지",
"VIP"
)
resolverTestVipStyle :=
resolverTestInstance.Resolve(
resolverTestVipMessage
)
;------------------------------------------------------------
; 각 스타일 검사
;------------------------------------------------------------
resolverTestNormalPassed :=
(
resolverTestNormalStyle.Color = "FFFFFF"
&& resolverTestNormalStyle.FontName = "맑은 고딕"
&& resolverTestNormalStyle.FontSize = 22
&& resolverTestNormalStyle.FontWeight = 400
)
resolverTestAdminPassed :=
(
resolverTestAdminStyle.Color = "FFD700"
&& resolverTestAdminStyle.FontWeight = 700
)
resolverTestMemberPassed :=
(
resolverTestMemberStyle.Color = "00FF7F"
&& resolverTestMemberStyle.FontWeight = 600
)
resolverTestDonationPassed :=
(
resolverTestDonationStyle.Color = "00BFFF"
&& resolverTestDonationStyle.FontWeight = 700
)
resolverTestNoticePassed :=
(
resolverTestNoticeStyle.Color = "FFA500"
&& resolverTestNoticeStyle.FontWeight = 700
)
resolverTestSystemPassed :=
(
resolverTestSystemStyle.Color = "C0C0C0"
&& resolverTestSystemStyle.FontWeight = 400
)
; 등록되지 않은 Unknown은 normal 스타일로 처리되어야 한다.
resolverTestFallbackPassed :=
(
resolverTestFallbackStyle.Color = "FFFFFF"
&& resolverTestFallbackStyle.FontWeight = 400
)
resolverTestVipPassed :=
(
resolverTestInstance.HasStyle("VIP")
&& resolverTestVipStyle.Color = "FF69B4"
&& resolverTestVipStyle.FontWeight = 800
)
;------------------------------------------------------------
; 잘못된 객체 거부 검사
;------------------------------------------------------------
resolverTestInvalidObjectRejected := false
try
{
resolverTestInstance.Resolve(
"ChatMessage 객체가 아닌 문자열"
)
}
catch TypeError
{
resolverTestInvalidObjectRejected := true
}
;------------------------------------------------------------
; 잘못된 동적 스타일 등록 거부 검사
;------------------------------------------------------------
resolverTestInvalidColorRejected := false
try
{
resolverTestInstance.RegisterStyle(
"invalidColor",
"PINK",
700
)
}
catch ValueError
{
resolverTestInvalidColorRejected := true
}
resolverTestInvalidWeightRejected := false
try
{
resolverTestInstance.RegisterStyle(
"invalidWeight",
"FFFFFF",
1200
)
}
catch ValueError
{
resolverTestInvalidWeightRejected := true
}
;------------------------------------------------------------
; 전체 테스트 결과
;
; 아래 조건이 모두 true일 때만 최종 성공이다.
;------------------------------------------------------------
resolverTestPassed :=
(
resolverTestNormalPassed
&& resolverTestAdminPassed
&& resolverTestMemberPassed
&& resolverTestDonationPassed
&& resolverTestNoticePassed
&& resolverTestSystemPassed
&& resolverTestFallbackPassed
&& resolverTestVipPassed
&& resolverTestInvalidObjectRejected
&& resolverTestInvalidColorRejected
&& resolverTestInvalidWeightRejected
)
;------------------------------------------------------------
; 결과 출력
;------------------------------------------------------------
resolverTestResultText :=
(
"일반 스타일: #"
. resolverTestNormalStyle.Color
. " / Weight "
. resolverTestNormalStyle.FontWeight
. "`n"
. "관리자 스타일: #"
. resolverTestAdminStyle.Color
. " / Weight "
. resolverTestAdminStyle.FontWeight
. "`n"
. "멤버 스타일: #"
. resolverTestMemberStyle.Color
. " / Weight "
. resolverTestMemberStyle.FontWeight
. "`n"
. "후원 스타일: #"
. resolverTestDonationStyle.Color
. " / Weight "
. resolverTestDonationStyle.FontWeight
. "`n"
. "공지 스타일: #"
. resolverTestNoticeStyle.Color
. " / Weight "
. resolverTestNoticeStyle.FontWeight
. "`n"
. "시스템 스타일: #"
. resolverTestSystemStyle.Color
. " / Weight "
. resolverTestSystemStyle.FontWeight
. "`n`n"
. "미등록 종류 기본 처리: "
. (resolverTestFallbackPassed ? "성공" : "실패")
. "`n"
. "동적 VIP 스타일 등록: "
. (resolverTestVipPassed ? "성공" : "실패")
. "`n"
. "잘못된 객체 거부: "
. (
resolverTestInvalidObjectRejected
? "성공"
: "실패"
)
. "`n"
. "잘못된 색상 등록 거부: "
. (
resolverTestInvalidColorRejected
? "성공"
: "실패"
)
. "`n"
. "잘못된 굵기 등록 거부: "
. (
resolverTestInvalidWeightRejected
? "성공"
: "실패"
)
. "`n`n"
. (
resolverTestPassed
? "StyleResolver 테스트 성공"
: "StyleResolver 테스트 실패"
)
)
MsgBox(
resolverTestResultText,
"Build 1.2 - StyleResolver Table Test"
)
🔴 Tests/TextDrawCommandTest.ahk — 그리기 명령 생성·잘못된 스타일 거부 검사 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Rendering\TextStyle.ahk
#Include ..\Engine\Rendering\TextDrawCommand.ahk
;============================================================
; TextDrawCommandTest
;
; TextStyle을 사용하는 명령 생성과 잘못된 스타일 거부를
; 검사한다.
;============================================================
testStyleInstance := TextStyle(
"FFD700",
"맑은 고딕",
24,
600
)
validCommandInstance := TextDrawCommand(
"TextStyle 연결 성공",
30,
50,
testStyleInstance
)
validCommandPassed :=
(
validCommandInstance.Text = "TextStyle 연결 성공"
&& validCommandInstance.X = 30
&& validCommandInstance.Y = 50
&& validCommandInstance.Style.Color = "FFD700"
&& validCommandInstance.Style.FontName = "맑은 고딕"
&& validCommandInstance.Style.FontSize = 24
&& validCommandInstance.Style.FontWeight = 600
)
invalidStyleRejected := false
try
{
invalidCommandInstance := TextDrawCommand(
"잘못된 스타일 테스트",
0,
0,
"FFD700"
)
}
catch TypeError
{
invalidStyleRejected := true
}
testPassed :=
(
validCommandPassed
&& invalidStyleRejected
)
resultText :=
(
validCommandInstance.ToDebugText()
. "`n`n"
. "정상 명령 생성: "
. (validCommandPassed ? "성공" : "실패")
. "`n"
. "잘못된 스타일 거부: "
. (invalidStyleRejected ? "성공" : "실패")
. "`n`n"
. (testPassed
? "TextDrawCommand 테스트 성공"
: "TextDrawCommand 테스트 실패")
)
MsgBox(
resultText,
"Build 1.1 - TextDrawCommand Test"
)
🔴 Tests/TextLayoutTest.ahk — 메시지 배열 → 좌표가 있는 그리기 명령 검사 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Model\ChatMessage.ahk
#Include ..\Engine\Rendering\TextStyle.ahk
#Include ..\Engine\Rendering\TextDrawCommand.ahk
#Include ..\Engine\Pipeline\TextLayout.ahk
;============================================================
; TextLayoutTest
;
; ChatMessage 배열이 올바른 좌표를 가진
; TextDrawCommand 배열로 변환되는지 검사한다.
;============================================================
normalStyleInstance := TextStyle(
"FFFFFF",
"맑은 고딕",
22,
400
)
textLayoutInstance := TextLayout(
30, ; 왼쪽 여백
40, ; 위쪽 여백
36 ; 줄 높이
)
messageObjects := []
messageObjects.Push(
ChatMessage(
"사용자 1",
"첫 번째 메시지"
)
)
messageObjects.Push(
ChatMessage(
"사용자 2",
"두 번째 메시지"
)
)
messageObjects.Push(
ChatMessage(
"관리자",
"세 번째 메시지",
"Admin"
)
)
testDrawCommands :=
textLayoutInstance.BuildCommands(
messageObjects,
normalStyleInstance
)
commandCountPassed :=
testDrawCommands.Length = 3
firstCommandPassed :=
(
testDrawCommands[1].Text
= "[사용자 1] 첫 번째 메시지"
&& testDrawCommands[1].X = 30
&& testDrawCommands[1].Y = 40
)
secondCommandPassed :=
(
testDrawCommands[2].Text
= "[사용자 2] 두 번째 메시지"
&& testDrawCommands[2].X = 30
&& testDrawCommands[2].Y = 76
)
thirdCommandPassed :=
(
testDrawCommands[3].Text
= "[관리자] 세 번째 메시지"
&& testDrawCommands[3].X = 30
&& testDrawCommands[3].Y = 112
)
stylePassed :=
(
testDrawCommands[1].Style.Color = "FFFFFF"
&& testDrawCommands[1].Style.FontName = "맑은 고딕"
&& testDrawCommands[1].Style.FontSize = 22
&& testDrawCommands[1].Style.FontWeight = 400
)
invalidLineIndexRejected := false
try
{
invalidCommandInstance :=
textLayoutInstance.BuildCommand(
messageObjects[1],
normalStyleInstance,
-1
)
}
catch ValueError
{
invalidLineIndexRejected := true
}
testPassed :=
(
commandCountPassed
&& firstCommandPassed
&& secondCommandPassed
&& thirdCommandPassed
&& stylePassed
&& invalidLineIndexRejected
)
resultText :=
(
"명령 개수: " testDrawCommands.Length
. "`n`n"
. "1번: "
. testDrawCommands[1].Text
. "`n좌표: "
. testDrawCommands[1].X
. ", "
. testDrawCommands[1].Y
. "`n`n"
. "2번: "
. testDrawCommands[2].Text
. "`n좌표: "
. testDrawCommands[2].X
. ", "
. testDrawCommands[2].Y
. "`n`n"
. "3번: "
. testDrawCommands[3].Text
. "`n좌표: "
. testDrawCommands[3].X
. ", "
. testDrawCommands[3].Y
. "`n`n"
. "잘못된 줄 번호 거부: "
. (invalidLineIndexRejected ? "성공" : "실패")
. "`n`n"
. (testPassed
? "TextLayout 테스트 성공"
: "TextLayout 테스트 실패")
)
MsgBox(
resultText,
"Build 1.1 - TextLayout Test"
)
🔴 Tests/TextStyleTest.ahk — TextStyle 생성·잘못된 속성 거부 검사 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Rendering\TextStyle.ahk
;============================================================
; TextStyleTest
;
; 정상적인 스타일 생성과 잘못된 속성 거부를 검사한다.
;============================================================
adminStyleInstance := TextStyle(
"FFD700",
"맑은 고딕",
24,
700
)
validStylePassed :=
(
adminStyleInstance.Color = "FFD700"
&& adminStyleInstance.FontName = "맑은 고딕"
&& adminStyleInstance.FontSize = 24
&& adminStyleInstance.FontWeight = 700
)
invalidColorRejected := false
invalidFontSizeRejected := false
invalidFontWeightRejected := false
try
{
invalidColorStyleInstance := TextStyle(
"YELLOW",
"맑은 고딕",
22,
400
)
}
catch ValueError
{
invalidColorRejected := true
}
try
{
invalidSizeStyleInstance := TextStyle(
"FFFFFF",
"맑은 고딕",
0,
400
)
}
catch ValueError
{
invalidFontSizeRejected := true
}
try
{
invalidWeightStyleInstance := TextStyle(
"FFFFFF",
"맑은 고딕",
22,
1200
)
}
catch ValueError
{
invalidFontWeightRejected := true
}
testPassed :=
(
validStylePassed
&& invalidColorRejected
&& invalidFontSizeRejected
&& invalidFontWeightRejected
)
resultText :=
(
adminStyleInstance.ToDebugText()
. "`n`n"
. "정상 스타일 생성: "
. (validStylePassed ? "성공" : "실패")
. "`n"
. "잘못된 색상 거부: "
. (invalidColorRejected ? "성공" : "실패")
. "`n"
. "잘못된 크기 거부: "
. (invalidFontSizeRejected ? "성공" : "실패")
. "`n"
. "잘못된 굵기 거부: "
. (invalidFontWeightRejected ? "성공" : "실패")
. "`n`n"
. (testPassed
? "TextStyle 테스트 성공"
: "TextStyle 테스트 실패")
)
MsgBox(
resultText,
"Build 1.0 - TextStyle Test"
)
🔴 Tests/TopFixedVisibleCountTest.ahk — 표시 개수 입력 + 위쪽 고정 테스트 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#UseHook True
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Configuration\OverlaySettingsLoader.ahk
#Include ..\Engine\Services\LiveOverlayController.ahk
#Include ..\Engine\Pipeline\VisibleMessageWindowSizer.ahk
#Include ..\Engine\Rendering\LayeredOverlayRenderer.ahk
;============================================================
; Build 2.6.3 - 표시 개수 숫자 입력 박스
;
; 표시 개수:
; - 숫자 입력 박스에 1~20 입력
; - 적용 버튼 클릭
; - 입력값을 Overlay.ini에도 저장
;
; 동작:
; - 오버레이 X/Y 고정
; - 오버레이 위쪽 위치 고정
; - 표시 개수를 줄이면 아래쪽 경계만 위로 이동
; - 메시지는 최신 항목 기준 유지
; - 표시 개수를 다시 늘리면 버퍼의 이전 메시지 재표시
;
; 키:
; - Enter / NumpadEnter: 새 메시지 추가
; - Esc: 종료
;============================================================
topFixedStylePath :=
A_ScriptDir
. "\..\Data\Settings\Styles_OBS20.ini"
topFixedSettingsPath :=
A_ScriptDir
. "\..\Data\Settings\Overlay.ini"
topFixedStyleLoader :=
StyleLoader()
topFixedStyleConfiguration :=
topFixedStyleLoader.Load(
topFixedStylePath
)
topFixedStyleResolver :=
StyleResolver(
topFixedStyleConfiguration
)
topFixedSettingsLoader :=
OverlaySettingsLoader()
topFixedSettings :=
topFixedSettingsLoader.Load(
topFixedSettingsPath
)
topFixedTextMeasurer :=
GdiTextMeasurer()
topFixedTextLayout :=
TextLayout(
topFixedSettings[
"LeftMargin"
],
topFixedSettings[
"TopMargin"
],
topFixedSettings[
"MinimumMessageHeight"
],
topFixedSettings[
"MessageGap"
],
topFixedTextMeasurer,
topFixedSettings[
"MaximumTextWidth"
],
topFixedSettings[
"TextLineGap"
]
)
topFixedWindowSizer :=
VisibleMessageWindowSizer(
topFixedTextLayout,
topFixedSettings[
"TopMargin"
],
topFixedSettings[
"BottomMargin"
]
)
topFixedCurrentLimit :=
topFixedSettings[
"VisibleMessageLimit"
]
topFixedCurrentHeight :=
topFixedWindowSizer.CalculateHeight(
topFixedCurrentLimit,
topFixedStyleConfiguration[
"Styles"
]
)
topFixedViewportManager :=
ViewportLayout(
topFixedTextLayout,
topFixedCurrentHeight,
topFixedSettings[
"TopMargin"
],
topFixedSettings[
"BottomMargin"
]
)
topFixedWindowOptions :=
"-Caption +ToolWindow +E0x80000"
if (
topFixedSettings[
"AlwaysOnTop"
]
)
{
topFixedWindowOptions .=
" +AlwaysOnTop"
}
if (
topFixedSettings[
"ClickThrough"
]
)
{
topFixedWindowOptions .=
" +E0x20"
}
topFixedOverlayWindow :=
Gui(
topFixedWindowOptions,
"OBSLiveChat Top Fixed Overlay"
)
topFixedOverlayWindow.Show(
"x"
. topFixedSettings["X"]
. " y"
. topFixedSettings["Y"]
. " w"
. topFixedSettings["Width"]
. " h"
. topFixedCurrentHeight
. " NA"
)
topFixedRenderer :=
LayeredOverlayRenderer(
topFixedOverlayWindow.Hwnd
)
topFixedController :=
LiveOverlayController(
topFixedStyleResolver,
topFixedTextLayout,
topFixedViewportManager,
topFixedRenderer,
topFixedSettings[
"BufferCapacity"
],
topFixedCurrentLimit
)
topFixedTotalAdded := 0
topFixedLastVisibleCount := 0
topFixedControlWindow :=
Gui(
"+AlwaysOnTop",
"OBSLiveChat 표시 개수와 투명창 높이 테스트"
)
topFixedControlWindow.SetFont(
"s10",
"맑은 고딕"
)
topFixedStatusText :=
topFixedControlWindow.AddText(
"x20 y18 w730 h58",
"초기화 중"
)
topFixedControlWindow.AddText(
"x20 y95 w75 h24",
"표시 개수"
)
topFixedLimitInput :=
topFixedControlWindow.AddEdit(
"x100 y88 w80 h32 Number Center",
topFixedCurrentLimit
)
topFixedApplyButton :=
topFixedControlWindow.AddButton(
"x195 y88 w105 h34 Default",
"적용"
)
topFixedAddButton :=
topFixedControlWindow.AddButton(
"x320 y88 w120 h34",
"새 메시지"
)
topFixedCloseButton :=
topFixedControlWindow.AddButton(
"x460 y88 w120 h34",
"종료"
)
topFixedApplyButton.OnEvent(
"Click",
ApplyTopFixedVisibleLimit
)
topFixedAddButton.OnEvent(
"Click",
AddTopFixedMessage
)
topFixedCloseButton.OnEvent(
"Click",
ExitTopFixedTest
)
topFixedControlWindow.OnEvent(
"Close",
ExitTopFixedTest
)
topFixedControlWindow.Show(
"x1040 y40 w620 h145"
)
FillTopFixedMessages()
SetTopFixedVisibleLimit(
topFixedCurrentLimit
)
ApplyTopFixedVisibleLimit(*)
{
global topFixedLimitInput
global topFixedSettings
global topFixedSettingsPath
global topFixedStatusText
rawValue :=
Trim(
topFixedLimitInput.Value
)
if (
rawValue = ""
|| !RegExMatch(
rawValue,
"^\d+$"
)
)
{
topFixedStatusText.Value :=
"표시 개수는 1~"
. topFixedSettings[
"BufferCapacity"
]
. " 사이의 숫자로 입력하세요."
topFixedLimitInput.Focus()
return
}
visibleMessageLimit := rawValue + 0
if (
visibleMessageLimit < 1
|| visibleMessageLimit
> topFixedSettings[
"BufferCapacity"
]
)
{
topFixedStatusText.Value :=
"표시 개수는 1~"
. topFixedSettings[
"BufferCapacity"
]
. " 사이여야 합니다."
topFixedLimitInput.Focus()
return
}
SetTopFixedVisibleLimit(
visibleMessageLimit
)
IniWrite(
visibleMessageLimit,
topFixedSettingsPath,
"Messages",
"VisibleMessageLimit"
)
topFixedLimitInput.Value :=
visibleMessageLimit
topFixedStatusText.Value .=
" / 설정 저장 완료"
topFixedLimitInput.Focus()
}
FillTopFixedMessages()
{
global topFixedController
global topFixedSettings
global topFixedTotalAdded
topFixedController.ClearMessages()
topFixedTotalAdded := 0
Loop topFixedSettings[
"BufferCapacity"
]
{
AddTopFixedMessage()
}
}
AddTopFixedMessage(*)
{
global topFixedTotalAdded
global topFixedController
global topFixedLastVisibleCount
topFixedTotalAdded += 1
styleTypes := [
"Normal",
"Member",
"Admin",
"Notice"
]
selectedStyle :=
styleTypes[
Mod(
topFixedTotalAdded - 1,
styleTypes.Length
)
+ 1
]
topFixedLastVisibleCount :=
topFixedController.AddMessage(
"사용자"
. Format(
"{:02}",
topFixedTotalAdded
),
"최근 메시지 "
. Format(
"{:02}",
topFixedTotalAdded
)
. " - 위쪽 고정 / 아래쪽 축소 테스트",
selectedStyle
)
UpdateTopFixedStatus()
}
SetTopFixedVisibleLimit(
visibleMessageLimit
)
{
global topFixedSettings
global topFixedWindowSizer
global topFixedStyleConfiguration
global topFixedViewportManager
global topFixedOverlayWindow
global topFixedController
global topFixedCurrentLimit
global topFixedCurrentHeight
global topFixedLastVisibleCount
global topFixedLimitInput
if (
visibleMessageLimit
> topFixedSettings[
"BufferCapacity"
]
)
{
throw ValueError(
"표시 개수는 버퍼 개수보다 클 수 없습니다."
)
}
topFixedCurrentLimit :=
visibleMessageLimit
topFixedCurrentHeight :=
topFixedWindowSizer.CalculateHeight(
visibleMessageLimit,
topFixedStyleConfiguration[
"Styles"
]
)
; X/Y를 같은 값으로 다시 지정한다.
; 따라서 창의 위쪽은 고정되고 아래쪽 경계만 움직인다.
WinMove(
topFixedSettings["X"],
topFixedSettings["Y"],
topFixedSettings["Width"],
topFixedCurrentHeight,
"ahk_id "
. topFixedOverlayWindow.Hwnd
)
topFixedViewportManager.ViewportHeight :=
topFixedCurrentHeight
topFixedLastVisibleCount :=
topFixedController.SetVisibleMessageLimit(
visibleMessageLimit
)
if (IsObject(topFixedLimitInput))
{
topFixedLimitInput.Value :=
visibleMessageLimit
}
UpdateTopFixedStatus()
}
UpdateTopFixedStatus()
{
global topFixedStatusText
global topFixedCurrentLimit
global topFixedCurrentHeight
global topFixedSettings
global topFixedTotalAdded
global topFixedLastVisibleCount
bottomY :=
topFixedSettings["Y"]
+ topFixedCurrentHeight
topFixedStatusText.Value :=
"표시 제한 "
. topFixedCurrentLimit
. "개 / 실제 화면 "
. topFixedLastVisibleCount
. "개 / 버퍼 최근 "
. topFixedSettings[
"BufferCapacity"
]
. "개 / 총 입력 "
. topFixedTotalAdded
. "개"
. "`n창 위쪽 Y="
. topFixedSettings["Y"]
. " 고정 / 높이="
. topFixedCurrentHeight
. " / 아래쪽 Y="
. bottomY
. " / 숫자 입력 후 적용"
}
ExitTopFixedTest(*)
{
global topFixedRenderer
topFixedRenderer.Shutdown()
ExitApp()
}
Enter::
{
AddTopFixedMessage()
}
NumpadEnter::
{
AddTopFixedMessage()
}
Esc::
{
ExitTopFixedTest()
}
🔴 Tests/TransparentOverlayTest.ahk — 투명 오버레이 기초 테스트 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Services\MessagePipeline.ahk
#Include ..\Engine\Rendering\OverlayRenderer.ahk
;============================================================
; TransparentOverlayTest
;
; Build 1.8
; - 메시지 사각 배경 없음
; - 창 캔버스 색상을 투명 처리
; - 테두리 없는 송출용 Overlay 형태
; - 마우스 클릭 통과
;
; 종료: Esc
;============================================================
;------------------------------------------------------------
; 투명 키 색상
;
; 000000을 사용하면 검은 외곽선과 그림자까지
; 사라질 수 있으므로 010101을 사용한다.
;------------------------------------------------------------
transparentOverlayKeyColor :=
"010101"
;------------------------------------------------------------
; 스타일 불러오기
;------------------------------------------------------------
transparentOverlayStylePath :=
A_ScriptDir "\..\Data\Settings\Styles.ini"
transparentOverlayStyleLoader :=
StyleLoader()
transparentOverlayStyleConfiguration :=
transparentOverlayStyleLoader.Load(
transparentOverlayStylePath
)
transparentOverlayStyleResolver :=
StyleResolver(
transparentOverlayStyleConfiguration
)
;------------------------------------------------------------
; 메시지 엔진 생성
;------------------------------------------------------------
transparentOverlayMessageBuffer :=
MessageBuffer(20)
transparentOverlayTextMeasurer :=
GdiTextMeasurer()
transparentOverlayTextLayout :=
TextLayout(
30, ; 왼쪽 여백
30, ; 위쪽 여백
40, ; 최소 줄 높이
12, ; 줄 사이 간격
transparentOverlayTextMeasurer
)
transparentOverlayMessagePipeline :=
MessagePipeline(
transparentOverlayMessageBuffer,
transparentOverlayStyleResolver,
transparentOverlayTextLayout
)
;------------------------------------------------------------
; 테스트 메시지
;------------------------------------------------------------
transparentOverlayMessageBuffer.Add(
ChatMessage(
"일반 사용자",
"투명 오버레이 일반 메시지입니다.",
"Normal"
)
)
transparentOverlayMessageBuffer.Add(
ChatMessage(
"관리자",
"관리자 메시지입니다.",
"Admin"
)
)
transparentOverlayMessageBuffer.Add(
ChatMessage(
"멤버",
"멤버 메시지입니다.",
"Member"
)
)
transparentOverlayMessageBuffer.Add(
ChatMessage(
"후원자",
"후원 메시지입니다.",
"Donation"
)
)
transparentOverlayMessageBuffer.Add(
ChatMessage(
"공지",
"공지 메시지입니다.",
"Notice"
)
)
transparentOverlayMessageBuffer.Add(
ChatMessage(
"시스템",
"시스템 메시지입니다.",
"System"
)
)
transparentOverlayMessageBuffer.Add(
ChatMessage(
"VIP",
"샴페인 골드 VIP 메시지입니다.",
"VIP"
)
)
transparentOverlayDrawCommands :=
transparentOverlayMessagePipeline.BuildDrawCommands()
;------------------------------------------------------------
; 투명 Overlay 창 생성
;
; +AlwaysOnTop : 항상 위
; -Caption : 제목 표시줄 제거
; +ToolWindow : 작업 표시줄 표시 최소화
; +E0x20 : 마우스 클릭 통과
;------------------------------------------------------------
transparentOverlayWindow :=
Gui(
"+AlwaysOnTop -Caption +ToolWindow +E0x20",
"OBSLiveChat Transparent Overlay"
)
transparentOverlayWindow.BackColor :=
transparentOverlayKeyColor
transparentOverlayWindow.Show(
"x20 y20 w1280 h720 NA"
)
;------------------------------------------------------------
; OverlayRenderer 연결
;------------------------------------------------------------
transparentOverlayRenderer :=
OverlayRenderer(
transparentOverlayWindow.Hwnd,
transparentOverlayKeyColor
)
transparentOverlayRenderer.SetDrawCommands(
transparentOverlayDrawCommands
)
;------------------------------------------------------------
; 캔버스 색상만 완전히 투명 처리
;------------------------------------------------------------
WinSetTransColor(
transparentOverlayKeyColor,
"ahk_id " transparentOverlayWindow.Hwnd
)
;------------------------------------------------------------
; 종료 안내
;------------------------------------------------------------
ToolTip(
"투명 Overlay 테스트 실행 중`n종료: Esc",
20,
760
)
SetTimer(
() => ToolTip(),
-3000
)
;------------------------------------------------------------
; 종료
;------------------------------------------------------------
Esc::
{
ExitApp()
}
🔴 Tests/ValueParserTest.ahk — 설정값 변환 검사 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Utilities\ValueParser.ahk
;============================================================
; ValueParserTest
;
; 설정값의 Boolean, Integer, Number, Byte,
; FontWeight, Color, 필수 문자열 변환을 검사한다.
;============================================================
;------------------------------------------------------------
; Boolean 변환 검사
;------------------------------------------------------------
valueParserTestTruePassed :=
(
ValueParser.ParseBoolean("True", "테스트")
&& ValueParser.ParseBoolean("YES", "테스트")
&& ValueParser.ParseBoolean("on", "테스트")
&& ValueParser.ParseBoolean("1", "테스트")
)
valueParserTestFalsePassed :=
(
!ValueParser.ParseBoolean("False", "테스트")
&& !ValueParser.ParseBoolean("NO", "테스트")
&& !ValueParser.ParseBoolean("off", "테스트")
&& !ValueParser.ParseBoolean("0", "테스트")
)
;------------------------------------------------------------
; 정수와 일반 숫자 변환 검사
;------------------------------------------------------------
valueParserTestIntegerPassed :=
(
ValueParser.ParseInteger(
"22",
"정수 테스트"
) = 22
&& ValueParser.ParseInteger(
"-3",
"정수 테스트"
) = -3
)
valueParserTestNumberPassed :=
(
ValueParser.ParseNumber(
"2.5",
"숫자 테스트"
) = 2.5
&& ValueParser.ParseNumber(
".75",
"숫자 테스트"
) = 0.75
)
;------------------------------------------------------------
; 양수와 0 이상 숫자 검사
;------------------------------------------------------------
valueParserTestPositiveValue :=
ValueParser.ParsePositiveNumber(
"22",
"양수 테스트"
)
valueParserTestNonNegativeValue :=
ValueParser.ParseNonNegativeNumber(
"0",
"0 이상 테스트"
)
valueParserTestPositivePassed :=
(
IsNumber(valueParserTestPositiveValue)
&& valueParserTestPositiveValue = 22
)
valueParserTestNonNegativePassed :=
(
IsNumber(valueParserTestNonNegativeValue)
&& valueParserTestNonNegativeValue = 0
)
;------------------------------------------------------------
; Byte 범위 검사
;------------------------------------------------------------
valueParserTestByteMinimumValue :=
ValueParser.ParseByte(
"0",
"바이트 테스트"
)
valueParserTestByteMaximumValue :=
ValueParser.ParseByte(
"255",
"바이트 테스트"
)
valueParserTestBytePassed :=
(
IsNumber(valueParserTestByteMinimumValue)
&& IsNumber(valueParserTestByteMaximumValue)
&& valueParserTestByteMinimumValue = 0
&& valueParserTestByteMaximumValue = 255
)
;------------------------------------------------------------
; FontWeight 범위 검사
;------------------------------------------------------------
valueParserTestFontWeightPassed :=
(
ValueParser.ParseFontWeight(
"700",
"글꼴 굵기 테스트"
) = 700
)
;------------------------------------------------------------
; 색상과 필수 문자열 검사
;------------------------------------------------------------
valueParserTestColorPassed :=
(
ValueParser.ParseColor(
"ff69b4",
"색상 테스트"
) = "FF69B4"
)
valueParserTestTextPassed :=
(
ValueParser.ParseRequiredText(
" 맑은 고딕 ",
"글꼴 이름 테스트"
) = "맑은 고딕"
)
;------------------------------------------------------------
; 잘못된 값 거부 검사
;------------------------------------------------------------
valueParserTestInvalidBooleanRejected := false
valueParserTestInvalidIntegerRejected := false
valueParserTestInvalidByteRejected := false
valueParserTestInvalidWeightRejected := false
valueParserTestInvalidColorRejected := false
valueParserTestEmptyTextRejected := false
try
{
ValueParser.ParseBoolean(
"Maybe",
"잘못된 논리값"
)
}
catch ValueError
{
valueParserTestInvalidBooleanRejected := true
}
try
{
ValueParser.ParseInteger(
"12.5",
"잘못된 정수"
)
}
catch ValueError
{
valueParserTestInvalidIntegerRejected := true
}
try
{
ValueParser.ParseByte(
"256",
"잘못된 바이트"
)
}
catch ValueError
{
valueParserTestInvalidByteRejected := true
}
try
{
ValueParser.ParseFontWeight(
"1200",
"잘못된 글꼴 굵기"
)
}
catch ValueError
{
valueParserTestInvalidWeightRejected := true
}
try
{
ValueParser.ParseColor(
"PINK",
"잘못된 색상"
)
}
catch ValueError
{
valueParserTestInvalidColorRejected := true
}
try
{
ValueParser.ParseRequiredText(
" ",
"빈 문자열"
)
}
catch ValueError
{
valueParserTestEmptyTextRejected := true
}
;------------------------------------------------------------
; 최종 성공 여부
;------------------------------------------------------------
valueParserTestPassed :=
(
valueParserTestTruePassed
&& valueParserTestFalsePassed
&& valueParserTestIntegerPassed
&& valueParserTestNumberPassed
&& valueParserTestPositivePassed
&& valueParserTestNonNegativePassed
&& valueParserTestBytePassed
&& valueParserTestFontWeightPassed
&& valueParserTestColorPassed
&& valueParserTestTextPassed
&& valueParserTestInvalidBooleanRejected
&& valueParserTestInvalidIntegerRejected
&& valueParserTestInvalidByteRejected
&& valueParserTestInvalidWeightRejected
&& valueParserTestInvalidColorRejected
&& valueParserTestEmptyTextRejected
)
;------------------------------------------------------------
; 결과 문자열 생성
; 긴 연속 표현식 대신 한 줄씩 안전하게 추가한다.
;------------------------------------------------------------
valueParserTestResultText := ""
valueParserTestResultText .=
"Boolean 참 변환: "
. (valueParserTestTruePassed ? "성공" : "실패")
. "`n"
valueParserTestResultText .=
"Boolean 거짓 변환: "
. (valueParserTestFalsePassed ? "성공" : "실패")
. "`n"
valueParserTestResultText .=
"Integer 변환: "
. (valueParserTestIntegerPassed ? "성공" : "실패")
. "`n"
valueParserTestResultText .=
"Number 변환: "
. (valueParserTestNumberPassed ? "성공" : "실패")
. "`n"
valueParserTestResultText .=
"양수 및 0 이상 검사: "
. (
valueParserTestPositivePassed
&& valueParserTestNonNegativePassed
? "성공"
: "실패"
)
. "`n"
valueParserTestResultText .=
"Byte 범위 검사: "
. (valueParserTestBytePassed ? "성공" : "실패")
. "`n"
valueParserTestResultText .=
"FontWeight 범위 검사: "
. (
valueParserTestFontWeightPassed
? "성공"
: "실패"
)
. "`n"
valueParserTestResultText .=
"Color 변환: "
. (valueParserTestColorPassed ? "성공" : "실패")
. "`n"
valueParserTestResultText .=
"필수 문자열 변환: "
. (valueParserTestTextPassed ? "성공" : "실패")
. "`n`n"
allInvalidValuesRejected :=
(
valueParserTestInvalidBooleanRejected
&& valueParserTestInvalidIntegerRejected
&& valueParserTestInvalidByteRejected
&& valueParserTestInvalidWeightRejected
&& valueParserTestInvalidColorRejected
&& valueParserTestEmptyTextRejected
)
valueParserTestResultText .=
"잘못된 값 거부: "
. (allInvalidValuesRejected ? "성공" : "실패")
. "`n`n"
valueParserTestResultText .=
(
valueParserTestPassed
? "ValueParser 테스트 성공"
: "ValueParser 테스트 실패"
)
;------------------------------------------------------------
; 결과 출력
;------------------------------------------------------------
MsgBox(
valueParserTestResultText,
"Build 1.2.3 - ValueParser Test"
)
🔴 Tests/ViewportOverlayTest.ahk — 뷰포트(위로 밀려 올라가기) 테스트 #
#Requires AutoHotkey v2.0+
#SingleInstance Force
#Warn All
#Include ..\Engine\Configuration\StyleLoader.ahk
#Include ..\Engine\Services\MessagePipeline.ahk
#Include ..\Engine\Pipeline\ViewportLayout.ahk
#Include ..\Engine\Rendering\LayeredOverlayRenderer.ahk
;============================================================
; Build 2.1 - Viewport Test
;
; Enter:
; - 새 메시지 추가
; - 기존 메시지는 위로 이동
; - 오래된 메시지는 화면에서 제외
;
; Esc:
; - 종료
;============================================================
viewportStylePath :=
A_ScriptDir
. "\..\Data\Settings\Styles.ini"
viewportStyleLoader :=
StyleLoader()
viewportStyleConfiguration :=
viewportStyleLoader.Load(
viewportStylePath
)
viewportStyleResolver :=
StyleResolver(
viewportStyleConfiguration
)
viewportMessageBuffer :=
MessageBuffer(20)
viewportTextMeasurer :=
GdiTextMeasurer()
viewportTextLayout :=
TextLayout(
45, ; 왼쪽 여백
25, ; 기본 위쪽 여백
40, ; 최소 메시지 높이
13, ; 메시지 사이 간격
viewportTextMeasurer,
720, ; 최대 텍스트 폭
5 ; 한 메시지 내부 줄 간격
)
viewportMessagePipeline :=
MessagePipeline(
viewportMessageBuffer,
viewportStyleResolver,
viewportTextLayout
)
viewportWindowWidth := 900
viewportWindowHeight := 720
viewportLayoutManager :=
ViewportLayout(
viewportTextLayout,
viewportWindowHeight,
25,
30
)
viewportWindow :=
Gui(
"+AlwaysOnTop -Caption +ToolWindow +E0x80000 +E0x20",
"OBSLiveChat Build 2.1 Viewport"
)
viewportWindow.Show(
"x20 y20 w"
. viewportWindowWidth
. " h"
. viewportWindowHeight
. " NA"
)
viewportRenderer :=
LayeredOverlayRenderer(
viewportWindow.Hwnd
)
viewportSamples := [
Map(
"Author", "일반1",
"Text", "오래된 일반 메시지입니다.",
"Type", "Normal"
),
Map(
"Author", "멤버1",
"Text", "오래된 멤버 메시지입니다.",
"Type", "Member"
),
Map(
"Author", "관리자",
"Text", "새 메시지가 들어오면 기존 메시지는 실제 높이만큼 위로 이동합니다.",
"Type", "Admin"
),
Map(
"Author", "후원자",
"Text", "두 줄이나 세 줄로 표시되는 긴 메시지도 하나의 메시지 높이로 계산되어 서로 겹치지 않습니다.",
"Type", "Donation"
),
Map(
"Author", "공지",
"Text", "화면 위쪽을 넘어간 오래된 메시지는 렌더링 목록에서 제외됩니다.",
"Type", "Notice"
),
Map(
"Author", "시스템",
"Text", "Enter를 눌러 새 메시지를 계속 추가하세요.",
"Type", "System"
),
Map(
"Author", "VIP",
"Text", "최신 메시지는 항상 화면 아래쪽 여백 위에 유지됩니다.",
"Type", "VIP"
),
Map(
"Author", "일반2",
"Text", "여덟 번째 메시지입니다. 오래된 항목이 화면 밖으로 밀려납니다.",
"Type", "Normal"
),
Map(
"Author", "멤버2",
"Text", "아홉 번째 메시지입니다.",
"Type", "Member"
),
Map(
"Author", "후원자2",
"Text", "열 번째 메시지입니다. 메시지를 추가할수록 위쪽 항목이 사라지는지 확인합니다.",
"Type", "Donation"
)
]
viewportSampleIndex := 0
viewportGeneratedCount := 0
Loop 7
{
AddNextViewportMessage()
}
ToolTip(
"Build 2.1 Viewport 테스트`n"
. "Enter: 새 메시지 추가`n"
. "Esc: 종료",
20,
755
)
SetTimer(
() => ToolTip(),
-5000
)
AddNextViewportMessage()
{
global viewportSamples
global viewportSampleIndex
global viewportGeneratedCount
global viewportMessageBuffer
global viewportMessagePipeline
global viewportLayoutManager
global viewportRenderer
viewportSampleIndex += 1
if (
viewportSampleIndex
<= viewportSamples.Length
)
{
sampleData :=
viewportSamples[
viewportSampleIndex
]
}
else
{
viewportGeneratedCount += 1
sampleData :=
Map(
"Author",
"새메시지"
. viewportGeneratedCount,
"Text",
"Enter로 추가된 최신 메시지 "
. viewportGeneratedCount
. "번입니다. 아래에 나타나고 기존 메시지는 위로 이동합니다.",
"Type",
Mod(
viewportGeneratedCount,
2
)
? "Normal"
: "Member"
)
}
viewportMessageBuffer.Add(
ChatMessage(
sampleData["Author"],
sampleData["Text"],
sampleData["Type"]
)
)
RefreshViewportOverlay()
}
RefreshViewportOverlay()
{
global viewportMessagePipeline
global viewportLayoutManager
global viewportRenderer
allDrawCommands :=
viewportMessagePipeline.BuildDrawCommands()
visibleDrawCommands :=
viewportLayoutManager.Apply(
allDrawCommands
)
viewportRenderer.SetDrawCommands(
visibleDrawCommands
)
}
Enter::AddNextViewportMessage()
NumpadEnter::AddNextViewportMessage()
Esc::
{
viewportRenderer.Shutdown()
ExitApp()
}